Compare commits
728 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 67952cf95e | |||
| 269e0b3261 | |||
| 3861d1c555 | |||
| bef6f67b70 | |||
| e6072eff59 | |||
| f9f7283e16 | |||
| cd1a2d2d5d | |||
| f5a7538637 | |||
| a5073a8770 | |||
| 575bfabfcb | |||
| 41b58cd027 | |||
| c7e1ecfe36 | |||
| 1df72ee093 | |||
| c0d9e32300 | |||
| 627fbe5874 | |||
| a5d39adf3e | |||
| 8e4af79cb2 | |||
| e72a2ad907 | |||
| 69241d06e7 | |||
| d9436fab69 | |||
| 8e9cbdd71b | |||
| 84120767a7 | |||
| 5a3985e6be | |||
| 41cefef95a | |||
| 370934afdf | |||
| 2296432e8b | |||
| 01652e8afb | |||
| f5d623ff3f | |||
| d5ab06ed68 | |||
| 0171ccbf62 | |||
| efee03fdec | |||
| 56a69b7eea | |||
| 19ae42af53 | |||
| f96365a181 | |||
| dda8170427 | |||
| 4f3ac95a39 | |||
| 2effea5806 | |||
| d34540ca30 | |||
| d2ef727064 | |||
| a23a471034 | |||
| bbe33fef41 | |||
| 52d7dfa253 | |||
| 9f6edab53b | |||
| fa7deaaa5c | |||
| f70726b43c | |||
| fe16b2a06d | |||
| 7bcbf197c1 | |||
| 68324c7263 | |||
| eb8d5b4408 | |||
| aec15905b5 | |||
| 70d71bc7bc | |||
| 34225b0380 | |||
| 6807ad0d1b | |||
| a4ca8ffa65 | |||
| 888766c10d | |||
| 9b602f55e0 | |||
| 763147e5f2 | |||
| 242676bac3 | |||
| aa68e0f8c9 | |||
| f1fe2b5c06 | |||
| 59e919ab4a | |||
| 421e529763 | |||
| bb03df8148 | |||
| 0d30a1eb72 | |||
| 8ee3e2c541 | |||
| 5a968e2f93 | |||
| ab7e676b54 | |||
| dcf6c20132 | |||
| 66fa2a1a8c | |||
| e6b17b6ea7 | |||
| 0124289f1a | |||
| 04d45f3c1c | |||
| 24592332e2 | |||
| 2db9df4491 | |||
| c0dfbdf143 | |||
| 0b63825a07 | |||
| a231c1a384 | |||
| c51b5a05db | |||
| 0dba2defd1 | |||
| 175be621cf | |||
| de0601d611 | |||
| 8968a00035 | |||
| ebe1b56c08 | |||
| a36cd0bd7b | |||
| 925b29836c | |||
| 91a4c2dce1 | |||
| 5e540e3439 | |||
| 4e14cc5207 | |||
| c5128db484 | |||
| 3e2477f255 | |||
| ed114ec341 | |||
| f1419bbc49 | |||
| e67d131514 | |||
| 829cfee29d | |||
| 5e36fd522c | |||
| 3969a8b58b | |||
| 856f0ab6f5 | |||
| 5435bceaf0 | |||
| 8bb7e17bf1 | |||
| d124fab642 | |||
| 4b093115e2 | |||
| 05dc83e522 | |||
| b6dab5fbf7 | |||
| 54eb6a5b42 | |||
| 8d254bd94e | |||
| f711abb236 | |||
| 86c1753e2b | |||
| 26b54cd144 | |||
| 3e2e2ac49e | |||
| 461c0d0d39 | |||
| 341c4329f4 | |||
| c8f34bbad7 | |||
| 418022943a | |||
| cfd02d959c | |||
| c505e8b207 | |||
| 43b61ce33c | |||
| bae69df8f9 | |||
| ac27cf8c07 | |||
| 308a0602b6 | |||
| 0eb25306ad | |||
| 8d9528545a | |||
| 2bbeff53f9 | |||
| 935bb99bed | |||
| 2ac31684f4 | |||
| c5cfefe3b2 | |||
| c7ce3e70da | |||
| 50dfc2082b | |||
| 86257ce7fc | |||
| ca31f1b782 | |||
| a7e8f98e33 | |||
| e3cf759968 | |||
| 1bc4eb5329 | |||
| e359f3cd23 | |||
| dc6d271293 | |||
| f239ca7ee3 | |||
| 9983c07e13 | |||
| 5a786edc3d | |||
| e61234f260 | |||
| d1f8fec1d3 | |||
| 88d3496a99 | |||
| a19c6fc988 | |||
| e76f947da2 | |||
| 0c0e3f0e4d | |||
| fcd5511403 | |||
| ffb8df9655 | |||
| e2aec2709b | |||
| 79c71d2d2c | |||
| fceac39143 | |||
| 31d38d4246 | |||
| 787b8b2a51 | |||
| 44c10bbe3c | |||
| 6b6eac2518 | |||
| 306fe4a91b | |||
| e96fdbed26 | |||
| 4bc420dc48 | |||
| 25ebebac5f | |||
| d170d27e80 | |||
| 8bc247d0c9 | |||
| 84995b7320 | |||
| c0b251ac52 | |||
| b39ba02bf0 | |||
| 27386d49d0 | |||
| 012a9e759e | |||
| 8e702d89bb | |||
| b103685170 | |||
| ad0dd1be5d | |||
| 663f7a3f12 | |||
| 2a4ef38a4f | |||
| 90b0adabc1 | |||
| ec2293a4e4 | |||
| 1a018c571b | |||
| f7baf45ae3 | |||
| 5a568d8a9b | |||
| 8df02f42c0 | |||
| 4fc4c01cea | |||
| 560c8ce0f6 | |||
| 50d1c7191a | |||
| 1c42a20865 | |||
| d72d312e1f | |||
| a071bfa8aa | |||
| 40a5c0476f | |||
| 760419a965 | |||
| 08a6a18226 | |||
| e7fc21e285 | |||
| 2b864cee9e | |||
| 88bb901283 | |||
| 1907f13c5f | |||
| ca0374b94f | |||
| 6cc864c048 | |||
| c6ae151f49 | |||
| 66ec98f647 | |||
| 971e36781b | |||
| cd04330ca6 | |||
| 935d2eb582 | |||
| 05130db571 | |||
| 92c5e97f85 | |||
| 3c9dab34bf | |||
| ce76d9d5a3 | |||
| f68a65697d | |||
| 7eb3ab0498 | |||
| 687261c827 | |||
| c063ac24a3 | |||
| 59af8349c6 | |||
| fd54512858 | |||
| 3d95c9256d | |||
| 1ac1af7db8 | |||
| 534bff2ff5 | |||
| 8ea09235f9 | |||
| ee605b34b6 | |||
| f7467cac50 | |||
| a973c35a02 | |||
| 3cea5f96f0 | |||
| 2f3ff6ced8 | |||
| 161465db55 | |||
| 85945af55e | |||
| 1cfe5de1c5 | |||
| df389d429c | |||
| 6651c1632d | |||
| 85a6d14fbb | |||
| c77c1b4bc2 | |||
| b2dc60c030 | |||
| e17fd0bb25 | |||
| 84872d970d | |||
| 03328d4f6d | |||
| 4a0ca4818f | |||
| 825480ae9b | |||
| 133b2de1ca | |||
| 9e4d213c2d | |||
| ee74df3d07 | |||
| d3200382f6 | |||
| 061635c36d | |||
| 02bb052d09 | |||
| 02dcd0e20d | |||
| e04877a638 | |||
| 9cfdbec2ef | |||
| cec667d309 | |||
| b46035823e | |||
| a3083f77c7 | |||
| a02617b66b | |||
| 137a48c215 | |||
| 91973e1e88 | |||
| 65407462d1 | |||
| fa641554e8 | |||
| 81c3948792 | |||
| ee4f0fc592 | |||
| 8469dbc045 | |||
| 92c217bd85 | |||
| 9e80322fe5 | |||
| ab3b3d5fca | |||
| 5457dd0c65 | |||
| 2ec3b09ca7 | |||
| f77a445bfe | |||
| 9724dbd36d | |||
| 9675ea90e2 | |||
| 6c68126486 | |||
| 9a0a6b7002 | |||
| 7f94235419 | |||
| d9f2aaf3b4 | |||
| 4f1cf6c9d8 | |||
| 82d4aaea0b | |||
| 785d8750ce | |||
| e3103f3a5e | |||
| 9a1ffe4121 | |||
| 07560eefc0 | |||
| 916ed284ce | |||
| 9557d456e8 | |||
| 883cf8afa9 | |||
| 8cd8b6d858 | |||
| af2c47b1a3 | |||
| 38bdae7016 | |||
| be79ae7d48 | |||
| 2a73362026 | |||
| 41f10e7b69 | |||
| 5817d2a301 | |||
| 2e3db274f1 | |||
| 8b73844f69 | |||
| a8f5af1245 | |||
| 085f1917db | |||
| 15f8967a8a | |||
| d402914eb7 | |||
| ab9298f382 | |||
| d5ab4fdeb8 | |||
| 898ba11ef0 | |||
| 25c80566e7 | |||
| 5f31ea3ffb | |||
| a4a319a76e | |||
| 6ed12ade54 | |||
| 25da224513 | |||
| 511bb469c4 | |||
| eff99f78fa | |||
| 8ff89c4288 | |||
| 913e461f79 | |||
| 59355431d0 | |||
| 71bc48dda4 | |||
| 6786ca2854 | |||
| 1f20cab110 | |||
| 2b6c229e4e | |||
| e94b27bce4 | |||
| 67941b4f80 | |||
| 7a369e0a30 | |||
| e86539db11 | |||
| 5fa3fdeca0 | |||
| a477d901d6 | |||
| ae38bbeab6 | |||
| 13a4cfa670 | |||
| 4c24adb471 | |||
| 5866ca48a9 | |||
| edad2d01da | |||
| 69e8c9e7b4 | |||
| acf34d4295 | |||
| 60cec022eb | |||
| 8e468c49cb | |||
| fb9ca7b830 | |||
| b6703b11c6 | |||
| 66d20cabac | |||
| e7f1192614 | |||
| da758ba712 | |||
| 894953db3d | |||
| 015a6f9e26 | |||
| 1fcc7caf99 | |||
| e6ead7d915 | |||
| 560d3c9fd0 | |||
| 32927b1a24 | |||
| c1ecc91aab | |||
| 1f4f0cee2a | |||
| 09ee844389 | |||
| fc0a493b72 | |||
| 2a46702fc5 | |||
| 44d3225932 | |||
| d9a83fc723 | |||
| eda7c66896 | |||
| e68923fa36 | |||
| 9fb710a04f | |||
| f262fb4811 | |||
| 773fc73280 | |||
| 50b5becfb0 | |||
| b5181aacd7 | |||
| 88f3691dcc | |||
| 49b340e039 | |||
| e872e18883 | |||
| 97dbd4dc5d | |||
| 3fb7892c07 | |||
| fefacc5bfd | |||
| 9692cc2e22 | |||
| cf5d48bb5a | |||
| 4b3d211e00 | |||
| 990be63c60 | |||
| 1b6d0c39e1 | |||
| e72927f3ab | |||
| c515085450 | |||
| cbb1e91372 | |||
| f017548a9c | |||
| 2a6fff9227 | |||
| 73f91e4690 | |||
| 9578ce9f77 | |||
| 49c7648af5 | |||
| 30e9ecbc96 | |||
| 82f494c99c | |||
| 4646f58072 | |||
| 95fb59696e | |||
| 71601f4971 | |||
| 823b02ac9c | |||
| 4b7c710755 | |||
| 927c241995 | |||
| f32748c929 | |||
| 8e5af82275 | |||
| 2042b575dc | |||
| f3eb662208 | |||
| 5e2253030f | |||
| 8c8344ca13 | |||
| a25deb939b | |||
| 766a2ad590 | |||
| 5fb9c33ecd | |||
| e847276d74 | |||
| 1c9677d37a | |||
| 0eed533b17 | |||
| aa9fa2bdff | |||
| d64c73dd74 | |||
| 570a1ffc2b | |||
| 4885ecc3ad | |||
| 18a97c6f59 | |||
| d225f2c6ba | |||
| 16e9b1eb1a | |||
| 45f81a7cd5 | |||
| d277e28427 | |||
| ffc24dcbe0 | |||
| 0ae8d5eeec | |||
| 3c10c7f5f4 | |||
| 5592f85c11 | |||
| 089659ffb1 | |||
| 90c34b74de | |||
| ed7de90a55 | |||
| 26ab0d37c1 | |||
| 8cadb33396 | |||
| b6f2a29b7e | |||
| d82364b9b5 | |||
| fa844d0878 | |||
| e906d0dc54 | |||
| b97043850b | |||
| 2789fb7cac | |||
| b2a16d46c6 | |||
| 6baaf205c8 | |||
| 87b3fe1afb | |||
| f496b149df | |||
| a4fbc74751 | |||
| d1c82f6c52 | |||
| e7bd04999f | |||
| bf4a6fb5b5 | |||
| 16384f8594 | |||
| f6130e25b2 | |||
| 2af698c43d | |||
| da05bbbdf7 | |||
| bf2f7b575e | |||
| 165b6fbc6a | |||
| 50ad4a8535 | |||
| bda76368bc | |||
| 1545979e6f | |||
| 641aacf793 | |||
| f15854c179 | |||
| 5f099ea488 | |||
| f9189772d7 | |||
| 56b963a940 | |||
| a5cc1970cf | |||
| 53f2449e4f | |||
| 30281852d6 | |||
| 531f7cd489 | |||
| 65ffa20ba2 | |||
| 1898f67fe0 | |||
| 5be6c7071e | |||
| 267b81af83 | |||
| e740aebf26 | |||
| 8dd567dd89 | |||
| dad89453d4 | |||
| 8f4ae5b6ac | |||
| 55fe26bdfa | |||
| ab78f9c2c5 | |||
| 587924fc42 | |||
| 70048acd73 | |||
| 1bbe37a602 | |||
| 8e743d28c8 | |||
| 3f6c4486f7 | |||
| 104d6608d9 | |||
| e83e6dc583 | |||
| c9a311331a | |||
| 75da08740c | |||
| 04ae4c036b | |||
| 9b76b10206 | |||
| 1882edaa9a | |||
| 9cd74f307c | |||
| 7f0f522b92 | |||
| a73dd4f45d | |||
| 6bfdccda2f | |||
| 3fc3b9f89f | |||
| f76e7b1dbd | |||
| 86fc3e09a3 | |||
| e7dd3f9378 | |||
| b1c400a7df | |||
| e26bc20723 | |||
| a672ae8c7d | |||
| 26a920a740 | |||
| 29dc5f66b8 | |||
| 288e7d1045 | |||
| a390b73386 | |||
| 88fc37d015 | |||
| 0b2296a843 | |||
| ddbae4da59 | |||
| a930cf42b4 | |||
| 9d1d0bce59 | |||
| 261154869d | |||
| a5fa54ff23 | |||
| d25f770488 | |||
| 88c18775d3 | |||
| e508057d1a | |||
| a7e5588a65 | |||
| cf794f1046 | |||
| 4c5bf42355 | |||
| 21e64943ac | |||
| 17f5e830c7 | |||
| 34268e6dee | |||
| 97bcd441f3 | |||
| dde9a43b7e | |||
| 1bd67b8064 | |||
| cffb723ecc | |||
| 46e0953876 | |||
| cc655672eb | |||
| ec4b397aa4 | |||
| 7635736be6 | |||
| ffe461ae58 | |||
| 506a81e3dc | |||
| 708abd37cf | |||
| 6add465365 | |||
| d2fac850cb | |||
| 10df2fd4fb | |||
| 90f77a3415 | |||
| ce2a7d49b1 | |||
| 8282e46813 | |||
| 01ec483ecc | |||
| b34a67e6cb | |||
| 3e15ee3ba0 | |||
| 670d5adfd7 | |||
| d88b824328 | |||
| 896f628473 | |||
| 00495568e4 | |||
| 9d8c3ca59c | |||
| 820306a32c | |||
| b0d5e0613e | |||
| a0bdb4fca2 | |||
| b217f2c210 | |||
| 3c5e292c5a | |||
| 9070fcd5e7 | |||
| 2ffefc3bbd | |||
| f622247b51 | |||
| 8b199c00e5 | |||
| 7d831e31c6 | |||
| df89e2c3b0 | |||
| 067362cf31 | |||
| 7d04bf2abe | |||
| 145faf4400 | |||
| 3e08bb4842 | |||
| 6449443c1f | |||
| 49b3ea02cc | |||
| dea329705a | |||
| eec406b739 | |||
| 585045b359 | |||
| f9c6220263 | |||
| afb806f71a | |||
| b43b721b1e | |||
| 9ea3e96447 | |||
| a0249bea61 | |||
| 54261b6e8b | |||
| a82f05a0a0 | |||
| dc18b0e442 | |||
| 65b4106eac | |||
| 1c9c450399 | |||
| fba03aea6e | |||
| 0783ca3793 | |||
| 6e06cf10e8 | |||
| c41bdc21cb | |||
| bf3224e373 | |||
| c9bf2a9099 | |||
| 05f38d6fe9 | |||
| 2a47efc546 | |||
| f75a54cd1e | |||
| 795ed3dc97 | |||
| d0fb054a55 | |||
| 7bc5b89f7a | |||
| adc5c1a131 | |||
| e8537067ef | |||
| 372fb1f345 | |||
| d15b4159ef | |||
| a2fb444911 | |||
| 0d27b59ebb | |||
| c8e299c8f1 | |||
| 2c47cda3d1 | |||
| 31b61d1bf8 | |||
| 4d8cc7594d | |||
| 72288c3685 | |||
| 173b7a2c83 | |||
| 940201313f | |||
| 47cb9abfc2 | |||
| 43d1f724b1 | |||
| 7c238f13e5 | |||
| 423ac04156 | |||
| 43f199a987 | |||
| 369b5d1c2d | |||
| d0b1c36d51 | |||
| 49787a4924 | |||
| 63602bf568 | |||
| fa49ccd058 | |||
| 7a5ae1e552 | |||
| ea156cce2e | |||
| eb04a7e7a5 | |||
| 75b058a475 | |||
| f9480ed576 | |||
| df39920fcd | |||
| 487cc0cd74 | |||
| fbbecf0846 | |||
| d49d57ec4b | |||
| 12cb4f1eb3 | |||
| 0727c98313 | |||
| 0d1096da6c | |||
| d4585fefb8 | |||
| 0a5e5544b1 | |||
| 5d7d8c3a9a | |||
| 906046c1cc | |||
| 0d67dfc215 | |||
| 1984932dc9 | |||
| 39498b6531 | |||
| 616fe7a3b1 | |||
| 61683f1961 | |||
| 3c40698033 | |||
| 614e40c0f5 | |||
| d1c537407d | |||
| e95239cfcd | |||
| 7851fb1c99 | |||
| 211ffabe39 | |||
| c5afaffa7e | |||
| e0a14f68fa | |||
| 9b4ab82044 | |||
| 8ab4d26474 | |||
| 8acba0ccff | |||
| 6f5544e0e4 | |||
| 4e44716b0c | |||
| fda71dadcb | |||
| 618c6dcaa4 | |||
| ae9d7f6b4c | |||
| 18c4368571 | |||
| 5325bec26c | |||
| 4895e011df | |||
| 6b1b3a2037 | |||
| 9b5d627a55 | |||
| 29acd25b4e | |||
| d2ee18c14f | |||
| 2ba4a62a0d | |||
| dc3519e973 | |||
| ee2c29d520 | |||
| efdd5d5a0c | |||
| de5ba47557 | |||
| e456799f1a | |||
| 5b7d204b9d | |||
| 1515d755e1 | |||
| 7ec88bf841 | |||
| dd8ebf10db | |||
| a029817d3d | |||
| ccc008eb5e | |||
| d898737d6d | |||
| 19d7281daf | |||
| 88f7505fdf | |||
| bf0aca35fa | |||
| b1409831a3 | |||
| 94db085b51 | |||
| 4e57b9fbdc | |||
| a55186cd02 | |||
| 9c0cc65973 | |||
| 459ee4e66a | |||
| 574e5d37c7 | |||
| 0d0ea981da | |||
| 0fa8f528c2 | |||
| 47805643f7 | |||
| 2a1bfb3e44 | |||
| 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 |
+34
-34
@@ -3,30 +3,30 @@ SHELL ["/bin/bash", "-o", "pipefail", "-c"]
|
||||
|
||||
ENV EDITOR=vim
|
||||
|
||||
RUN apt-get update && apt-get upgrade
|
||||
RUN apt-get update && apt-get upgrade --yes
|
||||
|
||||
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
|
||||
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
|
||||
@@ -39,22 +39,22 @@ RUN direnv hook bash >> $HOME/.bashrc
|
||||
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
|
||||
&& 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
|
||||
&& 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
|
||||
@@ -67,6 +67,7 @@ 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 \
|
||||
&& go install -v github.com/mikefarah/yq/v4@v4.30.6 \
|
||||
"
|
||||
|
||||
# install nodejs
|
||||
@@ -80,4 +81,3 @@ RUN bash -c "$(curl -fsSL https://raw.githubusercontent.com/horta/zstd.install/m
|
||||
RUN echo 'deb [trusted=yes] https://repo.goreleaser.com/apt/ /' | sudo tee /etc/apt/sources.list.d/goreleaser.list \
|
||||
&& apt update \
|
||||
&& apt install nfpm
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
// For format details, see https://aka.ms/devcontainer.json
|
||||
{
|
||||
"name": "Development environments on your infrastructure",
|
||||
"name": "Development environments on your infrastructure",
|
||||
|
||||
// Sets the run context to one level up instead of the .devcontainer folder.
|
||||
"context": ".",
|
||||
// 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",
|
||||
// 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",
|
||||
// Use 'forwardPorts' to make a list of ports inside the container available locally.
|
||||
// "forwardPorts": [],
|
||||
|
||||
// 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" ]
|
||||
"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,yml,tf,tfvars}]
|
||||
[*.{md,json,yaml,yml,tf,tfvars,nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<!--- Provide a general summary of the issue in the Title above -->
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
<!--- Tell us what should happen -->
|
||||
|
||||
## Current Behavior
|
||||
|
||||
<!--- Tell us what happens instead of the expected behavior -->
|
||||
@@ -38,7 +38,7 @@ updates:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
- version-update:semver-patch
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
@@ -53,7 +53,7 @@ updates:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
- 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"
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
###############################################################################
|
||||
# This file configures "Semantic Pull Requests", which is documented here:
|
||||
# https://github.com/zeke/semantic-pull-requests
|
||||
#
|
||||
# This action/spec implements the "Conventional Commits" RFC which is
|
||||
# available here:
|
||||
# https://www.notion.so/coderhq/Conventional-commits-1d51287f58b64026bb29393f277734ed
|
||||
###############################################################################
|
||||
|
||||
# We have no valid scopes right now.
|
||||
# A scope should be added when commits aren't aligning with associated change anymore.
|
||||
scopes:
|
||||
|
||||
# We only check that the PR title is semantic. The PR title is automatically
|
||||
# applied to the "Squash & Merge" flow as the suggested commit message, so this
|
||||
# should suffice unless someone drastically alters the message in that flow.
|
||||
titleOnly: true
|
||||
|
||||
# Types are the 'tag' types in a commit or PR title. For example, in
|
||||
#
|
||||
# chore: fix thing
|
||||
#
|
||||
# 'chore' is the type.
|
||||
types:
|
||||
# A build of any kind.
|
||||
- build
|
||||
|
||||
# Any code task that operates outside of CI, docs, or the product. Examples
|
||||
# include configurations, linters etc.
|
||||
- chore
|
||||
|
||||
# Any work performed on CI.
|
||||
- ci
|
||||
|
||||
- example
|
||||
|
||||
# Work that directly implements or supports the implementation of a feature.
|
||||
- feat
|
||||
|
||||
# A fix for either a released or unrelesed bug.
|
||||
- fix
|
||||
|
||||
# A fix for a released bug (regression fix) that is intended for patch-release
|
||||
# purposes.
|
||||
- hotfix
|
||||
|
||||
# A refactor changes code structure without any behavioral change.
|
||||
- refactor
|
||||
|
||||
# A git revert for any style of commit.
|
||||
- revert
|
||||
|
||||
# Adding tests of any kind. Should be separate from feature or fix
|
||||
# implementations. For example, if a commit adds a fix + test, it's a fix
|
||||
# commit. If a commit is simply bumping coverage, it's a test commit.
|
||||
- test
|
||||
@@ -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*
|
||||
@@ -0,0 +1,67 @@
|
||||
name: "CodeQL"
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
# run every week at 10:24 on Thursday
|
||||
- cron: "24 10 * * 4"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: ["go", "javascript"]
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v2
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Setup Go
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
|
||||
- name: Go Cache Paths
|
||||
if: matrix.language == 'go'
|
||||
id: go-cache-paths
|
||||
run: |
|
||||
echo "::set-output name=go-mod::$(go env GOMODCACHE)"
|
||||
|
||||
- name: Go Mod Cache
|
||||
if: matrix.language == 'go'
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.go-cache-paths.outputs.go-mod }}
|
||||
key: ${{ runner.os }}-release-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Remove Makefile # workaround to prevent CodeQL from building site
|
||||
if: matrix.language == 'go'
|
||||
run: |
|
||||
# Disable Analysis step from trying to build the project.
|
||||
rm Makefile
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v2
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
+102
-23
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
|
||||
pull_request:
|
||||
|
||||
@@ -36,7 +34,7 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
- name: typos-action
|
||||
uses: crate-ci/typos@master
|
||||
uses: crate-ci/typos@v1.13.3
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
- name: Fix Helper
|
||||
@@ -91,14 +89,14 @@ jobs:
|
||||
style-lint-golangci:
|
||||
name: style/lint/golangci
|
||||
timeout-minutes: 5
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
go-version: "~1.19"
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v3.2.0
|
||||
uses: golangci/golangci-lint-action@v3.3.1
|
||||
with:
|
||||
version: v1.48.0
|
||||
|
||||
@@ -173,7 +171,7 @@ jobs:
|
||||
gen:
|
||||
name: "style/gen"
|
||||
timeout-minutes: 8
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs-only == 'false'
|
||||
steps:
|
||||
@@ -224,6 +222,8 @@ jobs:
|
||||
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 yq
|
||||
run: go run github.com/mikefarah/yq/v4@v4.30.6
|
||||
|
||||
- name: Install Protoc
|
||||
run: |
|
||||
@@ -276,9 +276,12 @@ jobs:
|
||||
export PATH=${PATH}:$(go env GOPATH)/bin
|
||||
make --output-sync -j -B fmt
|
||||
|
||||
- name: Check for unstaged files
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
test-go:
|
||||
name: "test/go"
|
||||
runs-on: ${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-8-cores'|| matrix.os }}
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -312,12 +315,12 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
uses: jaxxstorm/action-install-gh-release@v1.9.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.7.0
|
||||
tag: v1.8.2
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -337,12 +340,37 @@ jobs:
|
||||
else
|
||||
echo ::set-output name=cover::false
|
||||
fi
|
||||
set -x
|
||||
test_timeout=5m
|
||||
if [[ "${{ matrix.os }}" == windows* ]]; then
|
||||
test_timeout=10m
|
||||
set +e
|
||||
gotestsum --junitfile="gotests.xml" --jsonfile="gotestsum.json" --packages="./..." --debug -- -parallel=8 -timeout=5m -short -failfast $COVERAGE_FLAGS
|
||||
ret=$?
|
||||
if ((ret)); then
|
||||
# Eternalize test timeout logs because "re-run failed" erases
|
||||
# artifacts and gotestsum doesn't always capture it:
|
||||
# https://github.com/gotestyourself/gotestsum/issues/292
|
||||
# Multiple test packages could've failed, each one may or may
|
||||
# not run into the edge case. PS. Don't summon ShellCheck here.
|
||||
for testWithStack in $(grep 'panic: test timed out' gotestsum.json | grep -E -o '("Test":[^,}]*)'); do
|
||||
if [ -n "$testWithStack" ] && grep -q "${testWithStack}.*PASS" gotestsum.json; then
|
||||
echo "Conditions met for gotestsum stack trace missing bug, outputting panic trace:"
|
||||
grep -A 999999 "${testWithStack}.*panic: test timed out" gotestsum.json
|
||||
fi
|
||||
done
|
||||
fi
|
||||
gotestsum --junitfile="gotests.xml" --packages="./..." -- -parallel=8 -timeout=$test_timeout -short -failfast $COVERAGE_FLAGS
|
||||
exit $ret
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: gotestsum-debug-${{ matrix.os }}.json
|
||||
path: ./gotestsum.json
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: gotests-${{ matrix.os }}.xml
|
||||
path: ./gotests.xml
|
||||
retention-days: 30
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
@@ -358,7 +386,7 @@ jobs:
|
||||
|
||||
test-go-postgres:
|
||||
name: "test/go/postgres"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
# 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
|
||||
@@ -390,12 +418,12 @@ jobs:
|
||||
key: ${{ runner.os }}-go-mod-${{ hashFiles('**/go.sum') }}
|
||||
|
||||
- name: Install gotestsum
|
||||
uses: jaxxstorm/action-install-gh-release@v1.7.1
|
||||
uses: jaxxstorm/action-install-gh-release@v1.9.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
repo: gotestyourself/gotestsum
|
||||
tag: v1.7.0
|
||||
tag: v1.8.2
|
||||
|
||||
- uses: hashicorp/setup-terraform@v2
|
||||
with:
|
||||
@@ -403,7 +431,38 @@ jobs:
|
||||
terraform_wrapper: false
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
run: make test-postgres
|
||||
run: |
|
||||
set +e
|
||||
make test-postgres
|
||||
ret=$?
|
||||
if ((ret)); then
|
||||
# Eternalize test timeout logs because "re-run failed" erases
|
||||
# artifacts and gotestsum doesn't always capture it:
|
||||
# https://github.com/gotestyourself/gotestsum/issues/292
|
||||
# Multiple test packages could've failed, each one may or may
|
||||
# not run into the edge case. PS. Don't summon ShellCheck here.
|
||||
for testWithStack in $(grep 'panic: test timed out' gotestsum.json | grep -E -o '("Test":[^,}]*)'); do
|
||||
if [ -n "$testWithStack" ] && grep -q "${testWithStack}.*PASS" gotestsum.json; then
|
||||
echo "Conditions met for gotestsum stack trace missing bug, outputting panic trace:"
|
||||
grep -A 999999 "${testWithStack}.*panic: test timed out" gotestsum.json
|
||||
fi
|
||||
done
|
||||
fi
|
||||
exit $ret
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: gotestsum-debug-postgres.json
|
||||
path: ./gotestsum.json
|
||||
retention-days: 7
|
||||
|
||||
- uses: actions/upload-artifact@v3
|
||||
if: success() || failure()
|
||||
with:
|
||||
name: gotests-postgres.xml
|
||||
path: ./gotests.xml
|
||||
retention-days: 30
|
||||
|
||||
- uses: codecov/codecov-action@v3
|
||||
# This action has a tendency to error out unexpectedly, it has
|
||||
@@ -419,7 +478,7 @@ jobs:
|
||||
|
||||
deploy:
|
||||
name: "deploy"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
needs: changes
|
||||
if: |
|
||||
@@ -434,13 +493,13 @@ jobs:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v0
|
||||
uses: google-github-actions/auth@v1
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Set up Google Cloud SDK
|
||||
uses: google-github-actions/setup-gcloud@v0
|
||||
uses: google-github-actions/setup-gcloud@v1
|
||||
|
||||
- uses: actions/setup-go@v3
|
||||
with:
|
||||
@@ -516,7 +575,7 @@ jobs:
|
||||
|
||||
test-js:
|
||||
name: "test/js"
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
@@ -630,7 +689,7 @@ jobs:
|
||||
with:
|
||||
name: failed-test-videos
|
||||
path: ./site/test-results/**/*.webm
|
||||
retention:days: 7
|
||||
retention-days: 7
|
||||
|
||||
chromatic:
|
||||
# REMARK: this is only used to build storybook and deploy it to Chromatic.
|
||||
@@ -676,3 +735,23 @@ jobs:
|
||||
buildScriptName: "storybook:build"
|
||||
projectToken: 695c25b6cb65
|
||||
workingDir: "./site"
|
||||
markdown-link-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@master
|
||||
# For the main branch:
|
||||
- if: github.ref == 'refs/heads/main' && !github.event.pull_request.head.repo.fork
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: yes
|
||||
use-verbose-mode: yes
|
||||
config-file: .github/workflows/mlc_config.json
|
||||
# For pull requests:
|
||||
- if: github.ref != 'refs/heads/main' || github.event.pull_request.head.repo.fork
|
||||
uses: gaurav-nelson/github-action-markdown-link-check@v1
|
||||
with:
|
||||
use-quiet-mode: yes
|
||||
use-verbose-mode: yes
|
||||
check-modified-files-only: yes
|
||||
base-branch: main
|
||||
config-file: .github/workflows/mlc_config.json
|
||||
|
||||
@@ -9,5 +9,5 @@ jobs:
|
||||
permissions:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: hmarr/auto-approve-action@v2
|
||||
- uses: hmarr/auto-approve-action@v3
|
||||
if: github.actor == 'dependabot[bot]'
|
||||
|
||||
@@ -4,8 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
tags:
|
||||
- "*"
|
||||
paths:
|
||||
- "dogfood/**"
|
||||
pull_request:
|
||||
@@ -14,12 +12,12 @@ on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
deploy_image:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Get branch name
|
||||
id: branch-name
|
||||
uses: tj-actions/branch-names@v6.1
|
||||
uses: tj-actions/branch-names@v6.4
|
||||
|
||||
- name: "Branch name to Docker tag name"
|
||||
id: docker-tag-name
|
||||
@@ -49,3 +47,27 @@ jobs:
|
||||
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
|
||||
deploy_template:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Get short commit SHA
|
||||
id: vars
|
||||
run: echo "::set-output name=sha_short::$(git rev-parse --short HEAD)"
|
||||
- name: "Install latest Coder"
|
||||
run: |
|
||||
curl -L https://coder.com/install.sh | sh
|
||||
# env:
|
||||
# VERSION: 0.x
|
||||
- name: "Push template"
|
||||
run: |
|
||||
coder templates push $CODER_TEMPLATE_NAME --directory $CODER_TEMPLATE_DIR --yes --name=$CODER_TEMPLATE_VERSION
|
||||
env:
|
||||
# Consumed by Coder CLI
|
||||
CODER_URL: https://dev.coder.com
|
||||
CODER_SESSION_TOKEN: ${{ secrets.CODER_SESSION_TOKEN }}
|
||||
# Template source & details
|
||||
CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }}
|
||||
CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }}
|
||||
CODER_TEMPLATE_DIR: ./dogfood
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "://.*.?example\\.com"
|
||||
},
|
||||
{
|
||||
"pattern": "developer.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "docs.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "support.google.com"
|
||||
},
|
||||
{
|
||||
"pattern": "tailscale.com"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
name: Submit Packages
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [release]
|
||||
types:
|
||||
- completed
|
||||
env:
|
||||
CODER_VERSION: "${{ github.event.release.tag_name }}"
|
||||
|
||||
jobs:
|
||||
winget:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Install wingetcreate
|
||||
run: |
|
||||
Invoke-WebRequest https://aka.ms/wingetcreate/latest -OutFile wingetcreate.exe
|
||||
|
||||
- name: Submit updated manifest to winget-pkgs
|
||||
run: |
|
||||
$release_assets = gh release view --repo coder/coder "$env:CODER_VERSION" --json assets | `
|
||||
ConvertFrom-Json
|
||||
# Get the installer URL from the release assets.
|
||||
$installer_url = $release_assets.assets | `
|
||||
Where-Object name -Match ".*_windows_amd64_installer.exe$" | `
|
||||
Select -ExpandProperty url
|
||||
|
||||
echo "Installer URL: $installer_url"
|
||||
|
||||
# The package version is the same as the tag minus the leading "v".
|
||||
$version = $env:CODER_VERSION.Trim('v')
|
||||
|
||||
echo "Package version: $version"
|
||||
|
||||
# The URL "|X64" suffix forces the architecture as it cannot be
|
||||
# sniffed properly from the URL. wingetcreate checks both the URL and
|
||||
# binary magic bytes for the architecture and they need to both match,
|
||||
# but they only check for `x64`, `win64` and `_64` in the URL. Our URL
|
||||
# contains `amd64` which doesn't match sadly.
|
||||
#
|
||||
# wingetcreate will still do the binary magic bytes check, so if we
|
||||
# accidentally change the architecture of the installer, it will fail
|
||||
# submission.
|
||||
.\wingetcreate.exe update Coder.Coder `
|
||||
--submit `
|
||||
--version "${version}" `
|
||||
--urls "${installer_url}|X64" `
|
||||
--token "${{ secrets.CDRCI_GITHUB_TOKEN }}"
|
||||
|
||||
env:
|
||||
# For gh CLI:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
|
||||
- name: Comment on PR
|
||||
run: |
|
||||
# find the PR that wingetcreate just made
|
||||
$pr_list = gh pr list --repo microsoft/winget-pkgs --search "author:cdrci Coder.Coder version ${{ steps.version.outputs.version }}" --limit 1 --json number | `
|
||||
ConvertFrom-Json`
|
||||
$pr_number = $pr_list[0].number
|
||||
|
||||
gh pr comment --repo microsoft/winget-pkgs "$pr_number" --body "🤖 cc: @deansheather @matifali"
|
||||
@@ -0,0 +1,20 @@
|
||||
name: Lint PR
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
- synchronize
|
||||
|
||||
jobs:
|
||||
main:
|
||||
name: Validate PR title
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: amannn/action-semantic-pull-request@v5
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
requireScope: false
|
||||
+136
-14
@@ -1,39 +1,71 @@
|
||||
# GitHub release workflow.
|
||||
name: release
|
||||
name: Release
|
||||
run-name: Release ${{ github.ref_name }}${{ inputs.dry_run && ' (DRYRUN)' || '' }}
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
snapshot:
|
||||
description: Force a dev version to be generated, implies dry_run.
|
||||
increment:
|
||||
description: Preferred version increment (release script may promote e.g. patch to minor depending on changes).
|
||||
type: choice
|
||||
required: true
|
||||
default: patch
|
||||
options:
|
||||
- patch
|
||||
- minor
|
||||
- major
|
||||
draft:
|
||||
description: Create a draft release (for manually editing release notes before publishing).
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
dry_run:
|
||||
description: Perform a dry-run release.
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
ignore_missing_commit_metadata:
|
||||
description: WARNING! This option disables the requirement that all commits have a PR. Not needed for dry_run.
|
||||
type: boolean
|
||||
default: false
|
||||
|
||||
permissions:
|
||||
# Required to publish a release
|
||||
contents: write
|
||||
# Necessary to push docker images to ghcr.io.
|
||||
packages: write
|
||||
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
|
||||
id-token: write
|
||||
|
||||
concurrency: ${{ github.workflow }}-${{ github.ref }}
|
||||
|
||||
env:
|
||||
CODER_RELEASE: ${{ github.event.inputs.snapshot && 'false' || 'true' }}
|
||||
# Use `inputs` (vs `github.event.inputs`) to ensure that booleans are actual
|
||||
# booleans, not strings.
|
||||
# https://github.blog/changelog/2022-06-10-github-actions-inputs-unified-across-manual-and-reusable-workflows/
|
||||
CODER_RELEASE: ${{ !inputs.dry_run }}
|
||||
CODER_RELEASE_INCREMENT: ${{ inputs.increment }}
|
||||
CODER_RELEASE_DRAFT: ${{ inputs.draft }}
|
||||
CODER_DRY_RUN: ${{ inputs.dry_run }}
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
name: Create and publish
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'ubuntu-latest-16-cores' || 'ubuntu-latest' }}
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
steps:
|
||||
- name: Check release on main (or dry-run)
|
||||
if: ${{ github.ref_name != 'main' && !inputs.dry_run }}
|
||||
run: |
|
||||
echo "Release not allowed on ${{ github.ref_name }}, use dry-run."
|
||||
exit 1
|
||||
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 0
|
||||
# Set token for pushing protected tag (vX.X.X).
|
||||
token: ${{ secrets.RELEASE_GITHUB_PAT }}
|
||||
|
||||
# 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
|
||||
@@ -43,6 +75,59 @@ jobs:
|
||||
- name: Fetch git tags
|
||||
run: git fetch --tags --force
|
||||
|
||||
# Configure git user name/email for creating annotated version tag.
|
||||
- name: Setup git config
|
||||
run: |
|
||||
git config user.name "Coder CI"
|
||||
git config user.email "dean+cdrci@coder.com"
|
||||
|
||||
- name: Create release tag and release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ref=HEAD
|
||||
old_version="$(git describe --abbrev=0 "$ref^1")"
|
||||
|
||||
if [[ "${{ inputs.ignore_missing_commit_metadata }}" == *t* ]]; then
|
||||
export CODER_IGNORE_MISSING_COMMIT_METADATA=1
|
||||
fi
|
||||
|
||||
# Warn if CODER_IGNORE_MISSING_COMMIT_METADATA is set any other way
|
||||
# than via dry-run.
|
||||
if [[ ${CODER_IGNORE_MISSING_COMMIT_METADATA:-0} != 0 ]]; then
|
||||
echo "WARNING: CODER_IGNORE_MISSING_COMMIT_METADATA is enabled and we will ignore missing commit metadata." 1>&2
|
||||
fi
|
||||
|
||||
version_args=()
|
||||
if [[ $CODER_DRY_RUN == *t* ]]; then
|
||||
# Allow dry-run of branches to pass.
|
||||
export CODER_IGNORE_MISSING_COMMIT_METADATA=1
|
||||
version_args+=(--dry-run)
|
||||
fi
|
||||
|
||||
# Cache commit metadata.
|
||||
. ./scripts/release/check_commit_metadata.sh "$old_version" "$ref"
|
||||
|
||||
declare -p version_args
|
||||
|
||||
# Create new release tag (note that this tag is not pushed before
|
||||
# release.sh is run).
|
||||
version="$(
|
||||
./scripts/release/tag_version.sh \
|
||||
"${version_args[@]}" \
|
||||
--ref "$ref" \
|
||||
--"$CODER_RELEASE_INCREMENT"
|
||||
)"
|
||||
|
||||
# Generate notes.
|
||||
release_notes_file="$(mktemp -t release_notes.XXXXXX)"
|
||||
./scripts/release/generate_release_notes.sh --old-version "$old_version" --new-version "$version" --ref "$ref" >> "$release_notes_file"
|
||||
echo CODER_RELEASE_NOTES_FILE="$release_notes_file" >> $GITHUB_ENV
|
||||
|
||||
- name: Echo release notes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cat "$CODER_RELEASE_NOTES_FILE"
|
||||
|
||||
- name: Docker Login
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
@@ -65,13 +150,14 @@ jobs:
|
||||
restore-keys: |
|
||||
js-${{ runner.os }}-
|
||||
|
||||
- name: Install nsis and zstd
|
||||
run: sudo apt-get install -y nsis zstd
|
||||
|
||||
- name: Install nfpm
|
||||
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: Install rcodesign
|
||||
run: |
|
||||
@@ -107,6 +193,7 @@ jobs:
|
||||
make -j \
|
||||
build/coder_"$version"_linux_{amd64,armv7,arm64}.{tar.gz,apk,deb,rpm} \
|
||||
build/coder_"$version"_{darwin,windows}_{amd64,arm64}.zip \
|
||||
build/coder_"$version"_windows_amd64_installer.exe \
|
||||
build/coder_helm_"$version".tgz
|
||||
env:
|
||||
CODER_SIGN_DARWIN: "1"
|
||||
@@ -153,8 +240,21 @@ jobs:
|
||||
|
||||
- name: Publish release
|
||||
run: |
|
||||
./scripts/publish_release.sh \
|
||||
${{ (github.event.inputs.dry_run || github.event.inputs.snapshot) && '--dry-run' }} \
|
||||
set -euo pipefail
|
||||
|
||||
publish_args=()
|
||||
if [[ $CODER_RELEASE_DRAFT == *t* ]]; then
|
||||
publish_args+=(--draft)
|
||||
fi
|
||||
if [[ $CODER_DRY_RUN == *t* ]]; then
|
||||
publish_args+=(--dry-run)
|
||||
fi
|
||||
declare -p publish_args
|
||||
|
||||
./scripts/release/publish.sh \
|
||||
"${publish_args[@]}" \
|
||||
--release-notes-file "$CODER_RELEASE_NOTES_FILE" \
|
||||
./build/*_installer.exe \
|
||||
./build/*.zip \
|
||||
./build/*.tar.gz \
|
||||
./build/*.tgz \
|
||||
@@ -164,12 +264,34 @@ jobs:
|
||||
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 }}
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v1
|
||||
with:
|
||||
workload_identity_provider: ${{ secrets.GCP_WORKLOAD_ID_PROVIDER }}
|
||||
service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}
|
||||
|
||||
- name: Setup GCloud SDK
|
||||
uses: "google-github-actions/setup-gcloud@v1"
|
||||
|
||||
- name: Publish Helm Chart
|
||||
if: ${{ !inputs.dry_run }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
version="$(./scripts/version.sh)"
|
||||
mkdir -p build/helm
|
||||
cp "build/coder_helm_${version}.tgz" build/helm
|
||||
gsutil cp gs://helm.coder.com/v2/index.yaml build/helm/index.yaml
|
||||
helm repo index build/helm --url https://helm.coder.com/v2 --merge build/helm/index.yaml
|
||||
gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/coder_helm_${version}.tgz gs://helm.coder.com/v2
|
||||
gsutil -h "Cache-Control:no-cache,max-age=0" cp build/helm/index.yaml gs://helm.coder.com/v2
|
||||
|
||||
- name: Upload artifacts to actions (if dry-run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: release-artifacts
|
||||
path: |
|
||||
./build/*_installer.exe
|
||||
./build/*.zip
|
||||
./build/*.tar.gz
|
||||
./build/*.tgz
|
||||
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
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
|
||||
- uses: actions/stale@v7.0.0
|
||||
with:
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
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
|
||||
|
||||
@@ -5,6 +5,12 @@ IST = "IST"
|
||||
MacOS = "macOS"
|
||||
|
||||
[default.extend-words]
|
||||
# do as sudo replacement
|
||||
doas = "doas"
|
||||
darcula = "darcula"
|
||||
Hashi = "Hashi"
|
||||
trialer = "trialer"
|
||||
encrypter = "encrypter"
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
|
||||
@@ -11,8 +11,8 @@ jobs:
|
||||
- uses: wow-actions/welcome@v1
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
FIRST_PR_REACTIONS: '+1, hooray, rocket, heart'
|
||||
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!
|
||||
👋 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! 👀👀👀
|
||||
|
||||
+31
-30
@@ -1,34 +1,35 @@
|
||||
###############################################################################
|
||||
# NOTICE #
|
||||
# If you change this file, kindly copy-pasta your change into .prettierignore #
|
||||
# and .eslintignore as well. See the following discussions to understand why #
|
||||
# we have to resort to this duplication (at least for now): #
|
||||
# #
|
||||
# https://github.com/prettier/prettier/issues/8048 #
|
||||
# https://github.com/prettier/prettier/issues/8506 #
|
||||
# https://github.com/prettier/prettier/issues/8679 #
|
||||
###############################################################################
|
||||
|
||||
node_modules
|
||||
vendor
|
||||
.eslintcache
|
||||
yarn-error.log
|
||||
gotests.coverage
|
||||
.idea
|
||||
.gitpod.yml
|
||||
# Common ignore patterns, these rules applies in both root and subdirectories.
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.gitpod.yml
|
||||
.idea
|
||||
**/*.swp
|
||||
gotests.coverage
|
||||
gotests.xml
|
||||
gotestsum.json
|
||||
node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# Front-end ignore
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
!/.vscode/extensions.json
|
||||
!/.vscode/settings.json
|
||||
|
||||
# Front-end ignore patterns.
|
||||
.next/
|
||||
site/.eslintcache
|
||||
site/.next/
|
||||
site/node_modules/
|
||||
site/storybook-static/
|
||||
site/test-results/
|
||||
site/yarn-error.log
|
||||
coverage/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
site/coverage/
|
||||
site/storybook-static/
|
||||
site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/storageState.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
@@ -41,8 +42,8 @@ site/out/
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
.vscode/*.log
|
||||
.vscode/launch.json
|
||||
**/*.swp
|
||||
.coderv2/*
|
||||
/.coderv2/*
|
||||
**/__debug_bin
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
|
||||
+11
-5
@@ -103,7 +103,7 @@ linters-settings:
|
||||
settings:
|
||||
ruleguard:
|
||||
failOn: all
|
||||
rules: '${configDir}/scripts/rules.go'
|
||||
rules: "${configDir}/scripts/rules.go"
|
||||
|
||||
staticcheck:
|
||||
# https://staticcheck.io/docs/options#checks
|
||||
@@ -123,6 +123,8 @@ linters-settings:
|
||||
|
||||
misspell:
|
||||
locale: US
|
||||
ignore-words:
|
||||
- trialer
|
||||
|
||||
nestif:
|
||||
min-complexity: 4 # Min complexity of if statements (def 5, goal 4)
|
||||
@@ -235,10 +237,15 @@ linters:
|
||||
- noctx
|
||||
- paralleltest
|
||||
- revive
|
||||
- rowserrcheck
|
||||
- sqlclosecheck
|
||||
|
||||
# These don't work until the following issue is solved.
|
||||
# https://github.com/golangci/golangci-lint/issues/2649
|
||||
# - rowserrcheck
|
||||
# - sqlclosecheck
|
||||
# - structcheck
|
||||
# - wastedassign
|
||||
|
||||
- staticcheck
|
||||
- structcheck
|
||||
- tenv
|
||||
# In Go, it's possible for a package to test it's internal functionality
|
||||
# without testing any exported functions. This is enabled to promote
|
||||
@@ -253,4 +260,3 @@ linters:
|
||||
- unconvert
|
||||
- unused
|
||||
- varcheck
|
||||
- wastedassign
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# Code generated by Makefile (.gitignore .prettierignore.include). DO NOT EDIT.
|
||||
|
||||
# .gitignore:
|
||||
# Common ignore patterns, these rules applies in both root and subdirectories.
|
||||
.DS_Store
|
||||
.eslintcache
|
||||
.gitpod.yml
|
||||
.idea
|
||||
**/*.swp
|
||||
gotests.coverage
|
||||
gotests.xml
|
||||
gotestsum.json
|
||||
node_modules/
|
||||
vendor/
|
||||
yarn-error.log
|
||||
|
||||
# VSCode settings.
|
||||
**/.vscode/*
|
||||
# Allow VSCode recommendations and default settings in project root.
|
||||
!/.vscode/extensions.json
|
||||
!/.vscode/settings.json
|
||||
|
||||
# Front-end ignore patterns.
|
||||
.next/
|
||||
site/**/*.typegen.ts
|
||||
site/build-storybook.log
|
||||
site/coverage/
|
||||
site/storybook-static/
|
||||
site/test-results/*
|
||||
site/e2e/test-results/*
|
||||
site/e2e/storageState.json
|
||||
site/playwright-report/*
|
||||
|
||||
# Make target for updating golden files.
|
||||
cli/testdata/.gen-golden
|
||||
|
||||
# Build
|
||||
/build/
|
||||
/dist/
|
||||
site/out/
|
||||
|
||||
*.tfstate
|
||||
*.tfstate.backup
|
||||
*.tfplan
|
||||
*.lock.hcl
|
||||
.terraform/
|
||||
|
||||
/.coderv2/*
|
||||
**/__debug_bin
|
||||
|
||||
# direnv
|
||||
.envrc
|
||||
# .prettierignore.include:
|
||||
# Helm templates contain variables that are invalid YAML and can't be formatted
|
||||
# by Prettier.
|
||||
helm/templates/*.yaml
|
||||
|
||||
# Terraform state files used in tests, these are automatically generated.
|
||||
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
|
||||
**/testdata/**/*.tf*.json
|
||||
|
||||
# Testdata shouldn't be formatted.
|
||||
scripts/apitypings/testdata/**/*.ts
|
||||
@@ -0,0 +1,10 @@
|
||||
# Helm templates contain variables that are invalid YAML and can't be formatted
|
||||
# by Prettier.
|
||||
helm/templates/*.yaml
|
||||
|
||||
# Terraform state files used in tests, these are automatically generated.
|
||||
# Example: provisioner/terraform/testdata/instance-id/instance-id.tfstate.json
|
||||
**/testdata/**/*.tf*.json
|
||||
|
||||
# Testdata shouldn't be formatted.
|
||||
scripts/apitypings/testdata/**/*.ts
|
||||
@@ -0,0 +1,16 @@
|
||||
# This config file is used in conjunction with `.editorconfig` to specify
|
||||
# formatting for prettier-supported files. See `.editorconfig` and
|
||||
# `site/.editorconfig`for whitespace formatting options.
|
||||
printWidth: 80
|
||||
semi: false
|
||||
trailingComma: all
|
||||
overrides:
|
||||
- files:
|
||||
- README.md
|
||||
options:
|
||||
proseWrap: preserve
|
||||
- files:
|
||||
- "site/**/*.yaml"
|
||||
- "site/**/*.yml"
|
||||
options:
|
||||
proseWrap: always
|
||||
@@ -0,0 +1,8 @@
|
||||
// Replace all NullTime with string
|
||||
replace github.com/coder/coder/codersdk.NullTime string
|
||||
// Prevent swaggo from rendering enums for time.Duration
|
||||
replace time.Duration int64
|
||||
// Do not expose "echo" provider
|
||||
replace github.com/coder/coder/codersdk.ProvisionerType string
|
||||
// Do not render netip.Addr
|
||||
replace netip.Addr string
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"github.vscode-codeql",
|
||||
"golang.go",
|
||||
"hashicorp.terraform",
|
||||
"esbenp.prettier-vscode",
|
||||
|
||||
Vendored
+21
-9
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"afero",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
@@ -9,23 +11,32 @@
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"codecov",
|
||||
"Codespaces",
|
||||
"coderd",
|
||||
"coderdenttest",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbtype",
|
||||
"DERP",
|
||||
"derphttp",
|
||||
"derpmap",
|
||||
"devel",
|
||||
"devtunnel",
|
||||
"dflags",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
"drpcmux",
|
||||
"drpcserver",
|
||||
"Dsts",
|
||||
"embeddedpostgres",
|
||||
"enablements",
|
||||
"errgroup",
|
||||
"eventsourcemock",
|
||||
"Failf",
|
||||
"fatih",
|
||||
"Formik",
|
||||
"gitauth",
|
||||
"gitsshkey",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
@@ -75,23 +86,28 @@
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promhttp",
|
||||
"promptui",
|
||||
"protobuf",
|
||||
"provisionerd",
|
||||
"provisionerdserver",
|
||||
"provisionersdk",
|
||||
"ptty",
|
||||
"ptys",
|
||||
"ptytest",
|
||||
"quickstart",
|
||||
"reconfig",
|
||||
"replicasync",
|
||||
"retrier",
|
||||
"rpty",
|
||||
"SCIM",
|
||||
"sdkproto",
|
||||
"sdktrace",
|
||||
"Signup",
|
||||
"slogtest",
|
||||
"sourcemapped",
|
||||
"Srcs",
|
||||
"stdbuf",
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
@@ -113,7 +129,9 @@
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tparallel",
|
||||
"trialer",
|
||||
"trimprefix",
|
||||
"tsdial",
|
||||
"tslogger",
|
||||
@@ -145,10 +163,7 @@
|
||||
"xstate",
|
||||
"yamux"
|
||||
],
|
||||
"cSpell.ignorePaths": [
|
||||
"site/package.json",
|
||||
".vscode/settings.json"
|
||||
],
|
||||
"cSpell.ignorePaths": ["site/package.json", ".vscode/settings.json"],
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
@@ -180,10 +195,7 @@
|
||||
// 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=./..."
|
||||
],
|
||||
"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"
|
||||
|
||||
+5
-4
@@ -1,4 +1,5 @@
|
||||
# Adopters
|
||||
# Adopters
|
||||
|
||||
[](https://coder.com/chat?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=adopters.md) [](https://twitter.com/coderhq)
|
||||
@@ -7,6 +8,6 @@ Follow](https://img.shields.io/twitter/follow/coderhq?label=%40coderhq&style=soc
|
||||
|
||||
> 👋 _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. |
|
||||
| Organization | Contact | Description of Use |
|
||||
| ------------------------------ | --------------------------------------- | ------------------------------ |
|
||||
| [Coder](https://www.coder.com) | [@coderhq](https://twitter.com/coderhq) | Coder builds coder with Coder. |
|
||||
|
||||
+7
-8
@@ -12,18 +12,17 @@ LABEL \
|
||||
org.opencontainers.image.description="A tool for provisioning self-hosted development environments with Terraform." \
|
||||
org.opencontainers.image.url="https://github.com/coder/coder" \
|
||||
org.opencontainers.image.source="https://github.com/coder/coder" \
|
||||
org.opencontainers.image.version="$CODER_VERSION" \
|
||||
org.opencontainers.image.licenses="AGPL-3.0"
|
||||
|
||||
# The coder binary is injected by scripts/build_docker.sh.
|
||||
COPY --chown=coder:coder --chmod=755 coder /opt/coder
|
||||
org.opencontainers.image.version="$CODER_VERSION"
|
||||
|
||||
# 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
|
||||
COPY --chown=0:0 --chmod=644 group passwd /etc/
|
||||
COPY --chown=1000:1000 --chmod=700 empty-dir /home/coder
|
||||
|
||||
USER coder:coder
|
||||
# The coder binary is injected by scripts/build_docker.sh.
|
||||
COPY --chown=1000:1000 --chmod=755 coder /opt/coder
|
||||
|
||||
USER 1000:1000
|
||||
ENV HOME=/home/coder
|
||||
ENV PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt
|
||||
WORKDIR /home/coder
|
||||
|
||||
@@ -44,6 +44,18 @@ else
|
||||
ZSTDFLAGS := -6
|
||||
endif
|
||||
|
||||
# Common paths to exclude from find commands, this rule is written so
|
||||
# that it can be it can be used in a chain of AND statements (meaning
|
||||
# you can simply write `find . $(FIND_EXCLUSIONS) -name thing-i-want`).
|
||||
# Note, all find statements should be written with `.` or `./path` as
|
||||
# the search path so that these exclusions match.
|
||||
FIND_EXCLUSIONS= \
|
||||
-not \( \( -path '*/.git/*' -o -path './build/*' -o -path './vendor/*' -o -path './.coderv2/*' -o -path '*/node_modules/*' -o -path './site/out/*' \) -prune \)
|
||||
# Source files used for make targets, evaluated on use.
|
||||
GO_SRC_FILES = $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
# All the shell files in the repo, excluding ignored files.
|
||||
SHELL_SRC_FILES = $(shell find . $(FIND_EXCLUSIONS) -type f -name '*.sh')
|
||||
|
||||
# All ${OS}_${ARCH} combos we build for. Windows binaries have the .exe suffix.
|
||||
OS_ARCHES := \
|
||||
linux_amd64 linux_arm64 linux_armv7 \
|
||||
@@ -96,19 +108,26 @@ build-fat build-full build: $(CODER_FAT_BINARIES)
|
||||
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)
|
||||
build/coder-slim_$(VERSION)_checksums.sha1: site/out/bin/coder.sha1
|
||||
cp "$<" "$@"
|
||||
|
||||
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
|
||||
# delete the uncompressed binaries from the embedded dir
|
||||
rm -f site/out/bin/coder-*
|
||||
|
||||
site/out/bin/coder.tar.zst: build/coder-slim_$(VERSION).tar.zst
|
||||
cp "$<" "$@"
|
||||
|
||||
build/coder-slim_$(VERSION).tar.zst: build/coder-slim_$(VERSION).tar
|
||||
zstd $(ZSTDFLAGS) \
|
||||
--force \
|
||||
--long \
|
||||
@@ -116,10 +135,6 @@ build/coder-slim_$(VERSION).tar.zst site/out/bin/coder.tar.zst: build/coder-slim
|
||||
-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.
|
||||
#
|
||||
@@ -171,7 +186,7 @@ endef
|
||||
# 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') \
|
||||
$(GO_SRC_FILES) \
|
||||
$(shell find ./examples/templates)
|
||||
|
||||
$(get-mode-os-arch-ext)
|
||||
@@ -252,6 +267,13 @@ $(CODER_ALL_PACKAGES): $(CODER_PACKAGE_DEPS)
|
||||
--output "$@" \
|
||||
"build/coder_$(VERSION)_$${os}_$${arch}"
|
||||
|
||||
# This task builds a Windows amd64 installer. Depends on makensis.
|
||||
build/coder_$(VERSION)_windows_amd64_installer.exe: build/coder_$(VERSION)_windows_amd64.exe
|
||||
./scripts/build_windows_installer.sh \
|
||||
--version "$(VERSION)" \
|
||||
--output "$@" \
|
||||
"$<"
|
||||
|
||||
# Redirect from version-less Docker image targets to the versioned ones.
|
||||
#
|
||||
# Called like this:
|
||||
@@ -326,7 +348,7 @@ build/coder_helm_$(VERSION).tgz:
|
||||
--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
|
||||
site/out/index.html: site/package.json $(shell find ./site $(FIND_EXCLUSIONS) -type f \( -name '*.ts' -o -name '*.tsx' \))
|
||||
./scripts/yarn_install.sh
|
||||
cd site
|
||||
yarn build
|
||||
@@ -357,13 +379,13 @@ fmt/terraform: $(wildcard *.tf)
|
||||
terraform fmt -recursive
|
||||
.PHONY: fmt/terraform
|
||||
|
||||
fmt/shfmt: $(shell shfmt -f .)
|
||||
fmt/shfmt: $(SHELL_SRC_FILES)
|
||||
echo "--- shfmt"
|
||||
# Only do diff check in CI, errors on diff.
|
||||
ifdef CI
|
||||
shfmt -d $(shell shfmt -f .)
|
||||
shfmt -d $(SHELL_SRC_FILES)
|
||||
else
|
||||
shfmt -w $(shell shfmt -f .)
|
||||
shfmt -w $(SHELL_SRC_FILES)
|
||||
endif
|
||||
.PHONY: fmt/shfmt
|
||||
|
||||
@@ -376,9 +398,9 @@ lint/go:
|
||||
.PHONY: lint/go
|
||||
|
||||
# Use shfmt to determine the shell files, takes editorconfig into consideration.
|
||||
lint/shellcheck: $(shell shfmt -f .)
|
||||
lint/shellcheck: $(SHELL_SRC_FILES)
|
||||
echo "--- shellcheck"
|
||||
shellcheck --external-sources $(shell shfmt -f .)
|
||||
shellcheck --external-sources $(SHELL_SRC_FILES)
|
||||
.PHONY: lint/shellcheck
|
||||
|
||||
# all gen targets should be added here and to gen/mark-fresh
|
||||
@@ -387,13 +409,33 @@ gen: \
|
||||
coderd/database/querier.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore
|
||||
.PHONY: gen
|
||||
|
||||
# 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"
|
||||
files="\
|
||||
coderd/database/dump.sql \
|
||||
coderd/database/querier.go \
|
||||
provisionersdk/proto/provisioner.pb.go \
|
||||
provisionerd/proto/provisionerd.pb.go \
|
||||
site/src/api/typesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
"
|
||||
for file in $$files; do
|
||||
echo "$$file"
|
||||
if [ ! -f "$$file" ]; then
|
||||
@@ -431,20 +473,103 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
--go-drpc_opt=paths=source_relative \
|
||||
./provisionerd/proto/provisionerd.proto
|
||||
|
||||
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find codersdk -type f -name '*.go')
|
||||
site/src/api/typesGenerated.ts: scripts/apitypings/main.go $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
go run scripts/apitypings/main.go > site/src/api/typesGenerated.ts
|
||||
cd site
|
||||
yarn run format:types
|
||||
|
||||
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
cd site
|
||||
yarn run format:write:only ../docs/admin/prometheus.md
|
||||
|
||||
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen -not \( -path './scripts/apidocgen/node_modules' -prune \) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) .swaggo
|
||||
./scripts/apidocgen/generate.sh
|
||||
cd site
|
||||
yarn run format:write:only ../docs/api ../docs/manifest.json ../coderd/apidoc/swagger.json
|
||||
|
||||
update-golden-files: cli/testdata/.gen-golden
|
||||
.PHONY: update-golden-files
|
||||
|
||||
cli/testdata/.gen-golden: $(wildcard cli/testdata/*.golden) $(GO_SRC_FILES)
|
||||
go test ./cli -run=TestCommandHelp -update
|
||||
touch "$@"
|
||||
|
||||
# Generate a prettierrc for the site package that uses relative paths for
|
||||
# overrides. This allows us to share the same prettier config between the
|
||||
# site and the root of the repo.
|
||||
site/.prettierrc.yaml: .prettierrc.yaml
|
||||
. ./scripts/lib.sh
|
||||
dependencies yq
|
||||
|
||||
echo "# Code generated by Makefile (../$<). DO NOT EDIT." > "$@"
|
||||
echo "" >> "$@"
|
||||
|
||||
# Replace all listed override files with relative paths inside site/.
|
||||
# - ./ -> ../
|
||||
# - ./site -> ./
|
||||
yq \
|
||||
'.overrides[].files |= map(. | sub("^./"; "") | sub("^"; "../") | sub("../site/"; "./"))' \
|
||||
"$<" >> "$@"
|
||||
|
||||
# Combine .gitignore with .prettierignore.include to generate .prettierignore.
|
||||
.prettierignore: .gitignore .prettierignore.include
|
||||
echo "# Code generated by Makefile ($^). DO NOT EDIT." > "$@"
|
||||
echo "" >> "$@"
|
||||
for f in $^; do
|
||||
echo "# $${f}:" >> "$@"
|
||||
cat "$$f" >> "$@"
|
||||
done
|
||||
|
||||
# Generate ignore files based on gitignore into the site directory. We turn all
|
||||
# rules into relative paths for the `site/` directory (where applicable),
|
||||
# following the pattern format defined by git:
|
||||
# https://git-scm.com/docs/gitignore#_pattern_format
|
||||
#
|
||||
# This is done for compatibility reasons, see:
|
||||
# https://github.com/prettier/prettier/issues/8048
|
||||
# https://github.com/prettier/prettier/issues/8506
|
||||
# https://github.com/prettier/prettier/issues/8679
|
||||
site/.eslintignore site/.prettierignore: .prettierignore Makefile
|
||||
rm -f "$@"
|
||||
touch "$@"
|
||||
# Skip generated by header, inherit `.prettierignore` header as-is.
|
||||
while read -r rule; do
|
||||
# Remove leading ! if present to simplify rule, added back at the end.
|
||||
tmp="$${rule#!}"
|
||||
ignore="$${rule%"$$tmp"}"
|
||||
rule="$$tmp"
|
||||
case "$$rule" in
|
||||
# Comments or empty lines (include).
|
||||
\#*|'') ;;
|
||||
# Generic rules (include).
|
||||
\*\**) ;;
|
||||
# Site prefixed rules (include).
|
||||
site/*) rule="$${rule#site/}";;
|
||||
./site/*) rule="$${rule#./site/}";;
|
||||
# Rules that are non-generic and don't start with site (rewrite).
|
||||
/*) rule=.."$$rule";;
|
||||
*/?*) rule=../"$$rule";;
|
||||
*) ;;
|
||||
esac
|
||||
echo "$${ignore}$${rule}" >> "$@"
|
||||
done < "$<"
|
||||
|
||||
test: test-clean
|
||||
gotestsum -- -v -short ./...
|
||||
gotestsum --debug -- -v -short ./...
|
||||
.PHONY: test
|
||||
|
||||
# When updating -timeout for this test, keep in sync with
|
||||
# test-go-postgres (.github/workflows/coder.yaml).
|
||||
test-postgres: test-clean test-postgres-docker
|
||||
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum --junitfile="gotests.xml" --packages="./..." -- \
|
||||
# The postgres test is prone to failure, so we limit parallelism for
|
||||
# more consistent execution.
|
||||
DB=ci DB_FROM=$(shell go run scripts/migrate-ci/main.go) gotestsum \
|
||||
--junitfile="gotests.xml" \
|
||||
--jsonfile="gotestsum.json" \
|
||||
--packages="./..." -- \
|
||||
-covermode=atomic -coverprofile="gotests.coverage" -timeout=20m \
|
||||
-parallel=4 \
|
||||
-coverpkg=./... \
|
||||
-count=1 -race -failfast
|
||||
.PHONY: test-postgres
|
||||
|
||||
@@ -7,7 +7,9 @@ Discord"](https://img.shields.io/badge/join-us%20on%20Discord-gray.svg?longCache
|
||||
[](https://twitter.com/coderhq)
|
||||
|
||||
Coder creates remote development machines so your team can develop from anywhere.
|
||||
Software development on your infrastructure. Offload your team's development from local workstations to cloud servers. Onboard developers in minutes. Build, test and compile at the speed of the cloud. Keep your source code and data behind your firewall.
|
||||
|
||||
> "By leveraging Terraform, Coder lets developers run any IDE on any compute platform including on-prem, AWS, Azure, GCP, DigitalOcean, Kubernetes, Docker, and more, with workspaces running on Linux, Windows, or Mac." - **Kevin Fishner Chief of Staff at [HashiCorp](https://hashicorp.com/)**
|
||||
|
||||
<p align="center">
|
||||
<img src="./docs/images/hero-image.png">
|
||||
@@ -27,12 +29,20 @@ Coder creates remote development machines so your team can develop from anywhere
|
||||
- Access your environment from any place on any client (even an iPad)
|
||||
- Onboard instantly then stay up to date continuously
|
||||
|
||||
## Recommended Reading
|
||||
|
||||
- [How our development team shares one giant bare metal machine](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
|
||||
- [Laptop development is dead: why remote development is the future](https://medium.com/@elliotgraebert/laptop-development-is-dead-why-remote-development-is-the-future-f92ce103fd13)
|
||||
- [Learn how Palantir improved build times by 78% with coder](https://blog.palantir.com/the-benefits-of-remote-ephemeral-workspaces-1a1251ed6e53).
|
||||
- [A software development environment is not just a container](https://coder.com/blog/not-a-container?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
- [What Coder is not](https://coder.com/docs/coder-oss/latest/index#what-coder-is-not?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
|
||||
## Getting Started
|
||||
|
||||
> **Note**:
|
||||
> Coder is in a beta state. [Report issues here](https://github.com/coder/coder/issues/new).
|
||||
|
||||
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.
|
||||
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. For Windows, use the latest `..._installer.exe` file from GitHub
|
||||
Releases.
|
||||
|
||||
To install, run:
|
||||
|
||||
@@ -56,11 +66,11 @@ curl -L https://coder.com/install.sh | sh -s -- --help
|
||||
|
||||
Once installed, you can start a production deployment<sup>1</sup> with a single command:
|
||||
|
||||
```sh
|
||||
```console
|
||||
# Automatically sets up an external access URL on *.try.coder.app
|
||||
coder server --tunnel
|
||||
coder server
|
||||
|
||||
# Requires a PostgreSQL instance and external access URL
|
||||
# Requires a PostgreSQL instance (version 13 or higher) and external access URL
|
||||
coder server --postgres-url <url> --access-url <url>
|
||||
```
|
||||
|
||||
@@ -72,20 +82,33 @@ Use `coder --help` to get a complete list of flags and environment variables. Us
|
||||
|
||||
Visit our docs [here](https://coder.com/docs/coder-oss).
|
||||
|
||||
## Templates
|
||||
|
||||
Find our templates [here](./examples/templates).
|
||||
|
||||
## Comparison
|
||||
|
||||
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to: [What Coder is not](https://coder.com/docs/coder-oss/latest/index#what-coder-is-not).
|
||||
Please file [an issue](https://github.com/coder/coder/issues/new) if any information is out of date. Also refer to:
|
||||
|
||||
| Tool | Type | Delivery Model | Cost | Environments |
|
||||
| :---------------------------------------------------------- | :------- | :----------------- | :---------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://github.com/coder/coder) | Platform | OSS + Self-Managed | Pay your cloud | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 |
|
||||
| [code-server](https://github.com/cdr/code-server) | Web IDE | OSS + Self-Managed | Pay your cloud | Linux, Mac, Windows, containers, VMs, amd64, arm64 |
|
||||
| [Coder (Classic)](https://coder.com/docs) | Platform | Self-Managed | Pay your cloud + license fees | Kubernetes Linux Containers |
|
||||
| [GitHub Codespaces](https://github.com/features/codespaces) | Platform | SaaS | 2x Azure Compute | Linux containers |
|
||||
- [What Coder is not](https://coder.com/docs/coder-oss/latest/index#what-coder-is-not?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
- [The Self-Hosting Paradox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
- [GitHub Codespaces, Coder, and Enterprise Customers](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md)
|
||||
- [How our development team shares one giant bare metal machine](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md).
|
||||
|
||||
---
|
||||
| Tool | Type | Delivery Model | Cost | Internet Access Required | Latency and Data Sovereignty | Security isolation model | Product quality | Service Availability | Environments | IDE |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------- | ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Coder](https://coder.com/blog/how-our-development-team-shares-one-giant-bare-metal-machine?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Unopinionated (whatever/wherever you choose to deploy thus 100% configurable) | [Defect history](https://github.com/coder/coder/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-Hosted | All [Terraform](https://www.terraform.io/registry/providers) resources, all clouds, multi-architecture: Linux, Mac, Windows, containers, VMs, amd64, arm64 | Anything (vim, emacs, theia, code-server, openvscode-server, entire jetbrains suite inc gateway remote development, visual studio code desktop, visual studio for mac, visual studio for windows) you choose to install and deploy |
|
||||
| [code-server](https://coder.com/blog/code-server-multiple-users?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/coder/code-server/issues?q=is%3Aissue+is%3Aopen+sort%3Aupdated-desc+label%3Abug) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64, arm64 | [code-server](https://github.com/coder/code-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
|
||||
| [openvscode-server](https://github.com/gitpod-io/openvscode-server) | Web IDE | OSS + Self-Managed | Pay your cloud | No | Self-Hosted | Self-Hosted docker container | [Defect history](https://github.com/gitpod-io/openvscode-server) | Self-hosted | Linux, Mac, Windows, containers, VMs, amd64 | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) |
|
||||
| [Amazon CodeCatalyst](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Pay AWS | Yes | US West (Oregon) | ["all customer multi-tenancy isolation is done through virtual machines" for security reasons](https://devclass.com/2022/12/05/interview-why-aws-prefers-vms-for-code-isolation-and-tips-on-developing-for-lambda/) | N/A | [Service Health](https://health.aws.amazon.com/health/status) | Linux Virtual Machines | Cloud9, Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [CodeAnywhere](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | Per user | Yes | N/A | N/A | N/A | N/A | N/A | Theia |
|
||||
| [GitHub Codespaces](https://coder.com/blog/github-codespaces-coder-and-enterprise-customers?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS | 2x Azure Compute | Yes | Four regions (US West, US East, Europe West, Southeast Asia) | ["two codespaces are never co-located on the same VM"](https://docs.github.com/en/codespaces/codespaces-reference/security-in-github-codespaces) | N/A | [Incident History](https://www.githubstatus.com/history) | Linux Virtual Machines, [GPUs supported](https://docs.github.com/en/codespaces/developing-in-codespaces/getting-started-with-github-codespaces-for-machine-learning) | Visual Studio Code ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [Gitpod](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | [SaaS](https://news.ycombinator.com/item?id=33907897) | [Credits](https://www.gitpod.io/pricing) | Yes | Two regions (Europe, US) | [All customers intermixed on the same machine isolated via runc](https://kinvolk.io/blog/2020/12/improving-kubernetes-and-container-security-with-user-namespaces/) | [Defect history](https://github.com/gitpod-io/gitpod/issues?q=is%3Aissue+label%3A%22type%3A+bug%22+sort%3Aupdated-desc+) | [Incident history](https://www.gitpodstatus.com/history) | Basic Linux containers, [GPUs](https://github.com/gitpod-io/gitpod/issues/10650) and [kubernetes/k3s](https://github.com/gitpod-io/gitpod/issues/4889) is not yet possible | [openvscode-server](https://github.com/gitpod-io/openvscode-server) (VSCode MIT) [with restrictions](https://ghuntley.com/fracture) inhibiting functionality of [.NET](https://www.isdotnetopen.com), [Python](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx), [C](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [C++](https://marketplace.visualstudio.com/items/ms-vscode.cpptools/license), [Jupyter](https://visualstudiomagazine.com/articles/2021/11/05/vscode-python-nov21.aspx) and usage of [GitHub Co-pilot](https://github.com/gitpod-io/gitpod/issues/10032). Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway supported |
|
||||
| [Google Cloud Workstations](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Google | Yes | southamerica-west1, us-east1, us-central1, us-west1, asia-east1, asia-southeast1, europe-north1, europe-southwest1, europe-west1, europe-west2, europe-west3, europe-west4 | N/A | N/A | Not generally available, offered in preview mode. | Linux | code-oss ([with restrictions](https://ghuntley.com/fracture)), Visual Studio Code Desktop ([no restrictions](https://ghuntley.com/fracture)) and JetBrains Gateway |
|
||||
| [JetBrains Space](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS + On-Prem ([Dev environments are not supported](https://www.jetbrains.com/help/space-on-premises/space-on-premises-installation.html)) | Pay JetBrains | Yes | EU Ireland region (eu-west-1) | EC2 | N/A | [Service Health](https://status.jetbrains.space/) | Linux Virtual Machines | JetBrains Suite |
|
||||
| [Microsoft DevBox](https://coder.com/blog/the-self-hosting-paradox?utm_source=github.com/coder/coder?utm_source=github.com/coder/coder&utm_medium=github&utm_campaign=readme.md) | Platform | SaaS (Preview, not GA) | Pay Microsoft | Yes | Australia East, Europe West, Japan East, Canada Central, UK South, US East, US East 2, US South Central, and US West 3 | Microsoft Azure Virtual Machine | N/A | Not generally available, offered in preview mode. | Windows Virtual Machine | Any application that runs on Windows via Microsoft Remote Desktop |
|
||||
|
||||
_Last updated: 5/27/22_
|
||||
_Last updated: 14/12/2022_
|
||||
|
||||
## Community and Support
|
||||
|
||||
@@ -95,7 +118,7 @@ Join our community on [Discord](https://coder.com/chat?utm_source=github.com/cod
|
||||
|
||||
## 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.
|
||||
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).
|
||||
|
||||
|
||||
+541
-261
File diff suppressed because it is too large
Load Diff
+978
-489
File diff suppressed because it is too large
Load Diff
+21
-24
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -23,28 +24,21 @@ type PostWorkspaceAgentAppHealth func(context.Context, codersdk.PostWorkspaceApp
|
||||
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 {
|
||||
func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.WorkspaceApp, 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)
|
||||
health := make(map[uuid.UUID]codersdk.WorkspaceAppHealth, 0)
|
||||
for _, app := range apps {
|
||||
health[app.Name] = app.Health
|
||||
if !hasHealthchecksEnabled && app.Health != codersdk.WorkspaceAppHealthDisabled {
|
||||
hasHealthchecksEnabled = true
|
||||
if app.Health == codersdk.WorkspaceAppHealthDisabled {
|
||||
continue
|
||||
}
|
||||
health[app.ID] = app.Health
|
||||
hasHealthchecksEnabled = true
|
||||
}
|
||||
|
||||
// no need to run this loop if no health checks are configured.
|
||||
@@ -54,14 +48,16 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp
|
||||
|
||||
// run a ticker for each app health check.
|
||||
var mu sync.RWMutex
|
||||
failures := make(map[string]int, 0)
|
||||
failures := make(map[uuid.UUID]int, 0)
|
||||
for _, nextApp := range apps {
|
||||
if !shouldStartTicker(nextApp) {
|
||||
continue
|
||||
}
|
||||
app := nextApp
|
||||
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
go func() {
|
||||
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
defer t.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
@@ -82,7 +78,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp
|
||||
return err
|
||||
}
|
||||
// successful healthcheck is a non-5XX status code
|
||||
res.Body.Close()
|
||||
_ = res.Body.Close()
|
||||
if res.StatusCode >= http.StatusInternalServerError {
|
||||
return xerrors.Errorf("error status code: %d", res.StatusCode)
|
||||
}
|
||||
@@ -91,21 +87,21 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp
|
||||
}()
|
||||
if err != nil {
|
||||
mu.Lock()
|
||||
if failures[app.Name] < int(app.Healthcheck.Threshold) {
|
||||
if failures[app.ID] < 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]++
|
||||
failures[app.ID]++
|
||||
} 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
|
||||
health[app.ID] = 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
|
||||
health[app.ID] = codersdk.WorkspaceAppHealthHealthy
|
||||
failures[app.ID] = 0
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -118,6 +114,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, workspaceAgentApps Worksp
|
||||
lastHealth := copyHealth(health)
|
||||
mu.Unlock()
|
||||
reportTicker := time.NewTicker(time.Second)
|
||||
defer reportTicker.Stop()
|
||||
// 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 {
|
||||
@@ -160,7 +157,7 @@ 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 {
|
||||
func healthChanged(old map[uuid.UUID]codersdk.WorkspaceAppHealth, new map[uuid.UUID]codersdk.WorkspaceAppHealth) bool {
|
||||
for name, newValue := range new {
|
||||
oldValue, found := old[name]
|
||||
if !found {
|
||||
@@ -174,8 +171,8 @@ func healthChanged(old map[string]codersdk.WorkspaceAppHealth, new map[string]co
|
||||
return false
|
||||
}
|
||||
|
||||
func copyHealth(h1 map[string]codersdk.WorkspaceAppHealth) map[string]codersdk.WorkspaceAppHealth {
|
||||
h2 := make(map[string]codersdk.WorkspaceAppHealth, 0)
|
||||
func copyHealth(h1 map[uuid.UUID]codersdk.WorkspaceAppHealth) map[uuid.UUID]codersdk.WorkspaceAppHealth {
|
||||
h2 := make(map[uuid.UUID]codersdk.WorkspaceAppHealth, 0)
|
||||
for k, v := range h1 {
|
||||
h2[k] = v
|
||||
}
|
||||
|
||||
+127
-130
@@ -19,148 +19,145 @@ import (
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestAppHealth(t *testing.T) {
|
||||
func TestAppHealth_Healthy(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,
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "app1",
|
||||
Healthcheck: codersdk.Healthcheck{},
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
{
|
||||
Slug: "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,
|
||||
},
|
||||
{
|
||||
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()
|
||||
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)
|
||||
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
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
})
|
||||
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,
|
||||
func TestAppHealth_500(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "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
|
||||
}
|
||||
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)
|
||||
})
|
||||
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,
|
||||
func TestAppHealth_Timeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "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
|
||||
}
|
||||
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)
|
||||
})
|
||||
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,
|
||||
func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
Slug: "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))
|
||||
})
|
||||
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()) {
|
||||
@@ -185,9 +182,9 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
}
|
||||
postWorkspaceAgentAppHealth := func(_ context.Context, req codersdk.PostWorkspaceAppHealthsRequest) error {
|
||||
mu.Lock()
|
||||
for name, health := range req.Healths {
|
||||
for id, health := range req.Healths {
|
||||
for i, app := range apps {
|
||||
if app.Name != name {
|
||||
if app.ID != id {
|
||||
continue
|
||||
}
|
||||
app.Health = health
|
||||
@@ -199,7 +196,7 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
return nil
|
||||
}
|
||||
|
||||
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), workspaceAgentApps, postWorkspaceAgentAppHealth)(ctx)
|
||||
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), apps, postWorkspaceAgentAppHealth)(ctx)
|
||||
|
||||
return workspaceAgentApps, func() {
|
||||
for _, closeFn := range closers {
|
||||
|
||||
@@ -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 < 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
|
||||
}
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
// streamLocalForwardPayload describes the extra data sent in a
|
||||
// streamlocal-forward@openssh.com containing the socket path to bind to.
|
||||
type streamLocalForwardPayload struct {
|
||||
SocketPath string
|
||||
}
|
||||
|
||||
// forwardedStreamLocalPayload describes the data sent as the payload in the new
|
||||
// channel request when a Unix connection is accepted by the listener.
|
||||
type forwardedStreamLocalPayload struct {
|
||||
SocketPath string
|
||||
Reserved uint32
|
||||
}
|
||||
|
||||
// forwardedUnixHandler is a clone of ssh.ForwardedTCPHandler that does
|
||||
// streamlocal forwarding (aka. unix forwarding) instead of TCP forwarding.
|
||||
type forwardedUnixHandler struct {
|
||||
sync.Mutex
|
||||
log slog.Logger
|
||||
forwards map[string]net.Listener
|
||||
}
|
||||
|
||||
func (h *forwardedUnixHandler) HandleSSHRequest(ctx ssh.Context, _ *ssh.Server, req *gossh.Request) (bool, []byte) {
|
||||
h.Lock()
|
||||
if h.forwards == nil {
|
||||
h.forwards = make(map[string]net.Listener)
|
||||
}
|
||||
h.Unlock()
|
||||
conn, ok := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
|
||||
if !ok {
|
||||
h.log.Warn(ctx, "SSH unix forward request from client with no gossh connection")
|
||||
return false, nil
|
||||
}
|
||||
|
||||
switch req.Type {
|
||||
case "streamlocal-forward@openssh.com":
|
||||
var reqPayload streamLocalForwardPayload
|
||||
err := gossh.Unmarshal(req.Payload, &reqPayload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "parse streamlocal-forward@openssh.com request payload from client", slog.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
|
||||
addr := reqPayload.SocketPath
|
||||
h.Lock()
|
||||
_, ok := h.forwards[addr]
|
||||
h.Unlock()
|
||||
if ok {
|
||||
h.log.Warn(ctx, "SSH unix forward request for socket path that is already being forwarded (maybe to another client?)",
|
||||
slog.F("socket_path", addr),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// Create socket parent dir if not exists.
|
||||
parentDir := filepath.Dir(addr)
|
||||
err = os.MkdirAll(parentDir, 0700)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "create parent dir for SSH unix forward request",
|
||||
slog.F("parent_dir", parentDir),
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
ln, err := net.Listen("unix", addr)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "listen on Unix socket for SSH unix forward request",
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// The listener needs to successfully start before it can be added to
|
||||
// the map, so we don't have to worry about checking for an existing
|
||||
// listener.
|
||||
//
|
||||
// This is also what the upstream TCP version of this code does.
|
||||
h.Lock()
|
||||
h.forwards[addr] = ln
|
||||
h.Unlock()
|
||||
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
go func() {
|
||||
defer cancel()
|
||||
|
||||
for {
|
||||
c, err := ln.Accept()
|
||||
if err != nil {
|
||||
if !xerrors.Is(err, net.ErrClosed) {
|
||||
h.log.Warn(ctx, "accept on local Unix socket for SSH unix forward request",
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
}
|
||||
// closed below
|
||||
break
|
||||
}
|
||||
payload := gossh.Marshal(&forwardedStreamLocalPayload{
|
||||
SocketPath: addr,
|
||||
})
|
||||
|
||||
go func() {
|
||||
ch, reqs, err := conn.OpenChannel("forwarded-streamlocal@openssh.com", payload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "open SSH channel to forward Unix connection to client",
|
||||
slog.F("socket_path", addr),
|
||||
slog.Error(err),
|
||||
)
|
||||
_ = c.Close()
|
||||
return
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
Bicopy(ctx, ch, c)
|
||||
}()
|
||||
}
|
||||
|
||||
h.Lock()
|
||||
ln2, ok := h.forwards[addr]
|
||||
if ok && ln2 == ln {
|
||||
delete(h.forwards, addr)
|
||||
}
|
||||
h.Unlock()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
|
||||
return true, nil
|
||||
|
||||
case "cancel-streamlocal-forward@openssh.com":
|
||||
var reqPayload streamLocalForwardPayload
|
||||
err := gossh.Unmarshal(req.Payload, &reqPayload)
|
||||
if err != nil {
|
||||
h.log.Warn(ctx, "parse cancel-streamlocal-forward@openssh.com request payload from client", slog.Error(err))
|
||||
return false, nil
|
||||
}
|
||||
h.Lock()
|
||||
ln, ok := h.forwards[reqPayload.SocketPath]
|
||||
h.Unlock()
|
||||
if ok {
|
||||
_ = ln.Close()
|
||||
}
|
||||
return true, nil
|
||||
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// directStreamLocalPayload describes the extra data sent in a
|
||||
// direct-streamlocal@openssh.com channel request containing the socket path.
|
||||
type directStreamLocalPayload struct {
|
||||
SocketPath string
|
||||
|
||||
Reserved1 string
|
||||
Reserved2 uint32
|
||||
}
|
||||
|
||||
func directStreamLocalHandler(_ *ssh.Server, _ *gossh.ServerConn, newChan gossh.NewChannel, ctx ssh.Context) {
|
||||
var reqPayload directStreamLocalPayload
|
||||
err := gossh.Unmarshal(newChan.ExtraData(), &reqPayload)
|
||||
if err != nil {
|
||||
_ = newChan.Reject(gossh.ConnectionFailed, "could not parse direct-streamlocal@openssh.com channel payload")
|
||||
return
|
||||
}
|
||||
|
||||
var dialer net.Dialer
|
||||
dconn, err := dialer.DialContext(ctx, "unix", reqPayload.SocketPath)
|
||||
if err != nil {
|
||||
_ = newChan.Reject(gossh.ConnectionFailed, fmt.Sprintf("dial unix socket %q: %+v", reqPayload.SocketPath, err.Error()))
|
||||
return
|
||||
}
|
||||
|
||||
ch, reqs, err := newChan.Accept()
|
||||
if err != nil {
|
||||
_ = dconn.Close()
|
||||
return
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
|
||||
Bicopy(ctx, ch, dconn)
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -4,7 +4,11 @@ import "os/exec"
|
||||
|
||||
// Get returns the command prompt binary name.
|
||||
func Get(username string) (string, error) {
|
||||
_, err := exec.LookPath("powershell.exe")
|
||||
_, err := exec.LookPath("pwsh.exe")
|
||||
if err == nil {
|
||||
return "pwsh.exe", nil
|
||||
}
|
||||
_, err = exec.LookPath("powershell.exe")
|
||||
if err == nil {
|
||||
return "powershell.exe", nil
|
||||
}
|
||||
|
||||
@@ -68,6 +68,11 @@ func VersionsMatch(v1, v2 string) bool {
|
||||
return semver.MajorMinor(v1) == semver.MajorMinor(v2)
|
||||
}
|
||||
|
||||
// IsDev returns true if this is a development build.
|
||||
func IsDev() bool {
|
||||
return strings.HasPrefix(Version(), develPrefix)
|
||||
}
|
||||
|
||||
// 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.
|
||||
|
||||
+37
-69
@@ -4,7 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
_ "net/http/pprof" //nolint: gosec
|
||||
"net/http/pprof"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -23,13 +23,11 @@ import (
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
func workspaceAgent() *cobra.Command {
|
||||
var (
|
||||
auth string
|
||||
pprofEnabled bool
|
||||
pprofAddress string
|
||||
noReap bool
|
||||
)
|
||||
@@ -38,6 +36,11 @@ func workspaceAgent() *cobra.Command {
|
||||
// This command isn't useful to manually execute.
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx, cancel := context.WithCancel(cmd.Context())
|
||||
defer cancel()
|
||||
|
||||
go dumpHandler(ctx)
|
||||
|
||||
rawURL, err := cmd.Flags().GetString(varAgentURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_URL must be set: %w", err)
|
||||
@@ -59,37 +62,35 @@ func workspaceAgent() *cobra.Command {
|
||||
// Spawn a reaper so that we don't accumulate a ton
|
||||
// of zombie processes.
|
||||
if reaper.IsInitProcess() && !noReap && isLinux {
|
||||
logger.Info(cmd.Context(), "spawning reaper process")
|
||||
logger.Info(ctx, "spawning reaper process")
|
||||
// Do not start a reaper on the child process. It's important
|
||||
// to do this else we fork bomb ourselves.
|
||||
args := append(os.Args, "--no-reap")
|
||||
err := reaper.ForkReap(reaper.WithExecArgs(args...))
|
||||
if err != nil {
|
||||
logger.Error(cmd.Context(), "failed to reap", slog.Error(err))
|
||||
logger.Error(ctx, "failed to reap", slog.Error(err))
|
||||
return xerrors.Errorf("fork reap: %w", err)
|
||||
}
|
||||
|
||||
logger.Info(cmd.Context(), "reaper process exiting")
|
||||
logger.Info(ctx, "reaper process exiting")
|
||||
return nil
|
||||
}
|
||||
|
||||
version := buildinfo.Version()
|
||||
logger.Info(cmd.Context(), "starting agent",
|
||||
logger.Info(ctx, "starting agent",
|
||||
slog.F("url", coderURL),
|
||||
slog.F("auth", auth),
|
||||
slog.F("version", version),
|
||||
)
|
||||
client := codersdk.New(coderURL)
|
||||
// Set a reasonable timeout so requests can't hang forever!
|
||||
client.HTTPClient.Timeout = 10 * time.Second
|
||||
|
||||
if pprofEnabled {
|
||||
srvClose := serveHandler(cmd.Context(), logger, nil, pprofAddress, "pprof")
|
||||
defer srvClose()
|
||||
} else {
|
||||
// If pprof wasn't enabled at startup, allow a
|
||||
// `kill -USR1 $agent_pid` to start it (on Unix).
|
||||
srvClose := agentStartPPROFOnUSR1(cmd.Context(), logger, pprofAddress)
|
||||
defer srvClose()
|
||||
}
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
_ = pprof.Handler
|
||||
pprofSrvClose := serveHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer pprofSrvClose()
|
||||
|
||||
// exchangeToken returns a session token.
|
||||
// This is abstracted to allow for the same looping condition
|
||||
@@ -101,12 +102,12 @@ func workspaceAgent() *cobra.Command {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("CODER_AGENT_TOKEN must be set for token auth: %w", err)
|
||||
}
|
||||
client.SessionToken = token
|
||||
client.SetSessionToken(token)
|
||||
case "google-instance-identity":
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var gcpClient *metadata.Client
|
||||
gcpClientRaw := cmd.Context().Value("gcp-client")
|
||||
gcpClientRaw := ctx.Value("gcp-client")
|
||||
if gcpClientRaw != nil {
|
||||
gcpClient, _ = gcpClientRaw.(*metadata.Client)
|
||||
}
|
||||
@@ -117,7 +118,7 @@ func workspaceAgent() *cobra.Command {
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var awsClient *http.Client
|
||||
awsClientRaw := cmd.Context().Value("aws-client")
|
||||
awsClientRaw := ctx.Value("aws-client")
|
||||
if awsClientRaw != nil {
|
||||
awsClient, _ = awsClientRaw.(*http.Client)
|
||||
if awsClient != nil {
|
||||
@@ -131,7 +132,7 @@ func workspaceAgent() *cobra.Command {
|
||||
// This is *only* done for testing to mock client authentication.
|
||||
// This will never be set in a production scenario.
|
||||
var azureClient *http.Client
|
||||
azureClientRaw := cmd.Context().Value("azure-client")
|
||||
azureClientRaw := ctx.Value("azure-client")
|
||||
if azureClientRaw != nil {
|
||||
azureClient, _ = azureClientRaw.(*http.Client)
|
||||
if azureClient != nil {
|
||||
@@ -143,43 +144,6 @@ 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
|
||||
// tons of idle agents from pinging coderd.
|
||||
ctx, cancelFunc := context.WithTimeout(cmd.Context(), time.Hour)
|
||||
defer cancelFunc()
|
||||
for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) {
|
||||
var response codersdk.WorkspaceAgentAuthenticateResponse
|
||||
|
||||
response, err = exchangeToken(ctx)
|
||||
if err != nil {
|
||||
logger.Warn(ctx, "authenticate workspace", slog.F("method", auth), slog.Error(err))
|
||||
continue
|
||||
}
|
||||
client.SessionToken = response.SessionToken
|
||||
logger.Info(ctx, "authenticated", slog.F("method", auth))
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("agent failed to authenticate in time: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(cmd.Context(), time.Hour)
|
||||
defer cancelFunc()
|
||||
for retry.New(100*time.Millisecond, 5*time.Second).Wait(ctx) {
|
||||
err := client.PostWorkspaceAgentVersion(cmd.Context(), version)
|
||||
if err != nil {
|
||||
logger.Warn(cmd.Context(), "post agent version: %w", slog.Error(err), slog.F("version", version))
|
||||
continue
|
||||
}
|
||||
logger.Info(ctx, "updated agent version", slog.F("version", version))
|
||||
break
|
||||
}
|
||||
|
||||
executablePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("getting os executable: %w", err)
|
||||
@@ -190,25 +154,29 @@ func workspaceAgent() *cobra.Command {
|
||||
}
|
||||
|
||||
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,
|
||||
Client: client,
|
||||
Logger: logger,
|
||||
ExchangeToken: func(ctx context.Context) (string, error) {
|
||||
if exchangeToken == nil {
|
||||
return client.SessionToken(), nil
|
||||
}
|
||||
resp, err := exchangeToken(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
return resp.SessionToken, nil
|
||||
},
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GIT_ASKPASS": executablePath,
|
||||
},
|
||||
CoordinatorDialer: client.ListenWorkspaceAgentTailnet,
|
||||
StatsReporter: client.AgentReportStats,
|
||||
WorkspaceAgentApps: client.WorkspaceAgentApps,
|
||||
PostWorkspaceAgentAppHealth: client.PostWorkspaceAgentAppHealth,
|
||||
})
|
||||
<-cmd.Context().Done()
|
||||
<-ctx.Done()
|
||||
return closer.Close()
|
||||
},
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
+38
-28
@@ -2,18 +2,18 @@ package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"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) {
|
||||
@@ -29,7 +29,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -60,19 +60,17 @@ 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)
|
||||
if assert.NotEmpty(t, resources) && assert.NotEmpty(t, resources[0].Agents) {
|
||||
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)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := dialer.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
require.True(t, dialer.AwaitReachable(context.Background()))
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
@@ -89,7 +87,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -120,19 +118,17 @@ 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)
|
||||
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)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := dialer.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
require.True(t, dialer.AwaitReachable(context.Background()))
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
@@ -149,7 +145,7 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -180,19 +176,33 @@ 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)
|
||||
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)
|
||||
dialer, err := client.DialWorkspaceAgent(ctx, resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer dialer.Close()
|
||||
require.Eventually(t, func() bool {
|
||||
_, err := dialer.Ping()
|
||||
return err == nil
|
||||
}, testutil.WaitMedium, testutil.IntervalFast)
|
||||
require.True(t, dialer.AwaitReachable(context.Background()))
|
||||
sshClient, err := dialer.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
key := "CODER_AGENT_TOKEN"
|
||||
command := "sh -c 'echo $" + key + "'"
|
||||
if runtime.GOOS == "windows" {
|
||||
command = "cmd.exe /c echo %" + key + "%"
|
||||
}
|
||||
token, err := session.CombinedOutput(command)
|
||||
require.NoError(t, err)
|
||||
_, err = uuid.Parse(strings.TrimSpace(string(token)))
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
err = <-errC
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
//go:build !windows
|
||||
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
func agentStartPPROFOnUSR1(ctx context.Context, logger slog.Logger, pprofAddress string) (srvClose func()) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
|
||||
usr1 := make(chan os.Signal, 1)
|
||||
signal.Notify(usr1, syscall.SIGUSR1)
|
||||
go func() {
|
||||
defer close(usr1)
|
||||
defer signal.Stop(usr1)
|
||||
|
||||
select {
|
||||
case <-usr1:
|
||||
signal.Stop(usr1)
|
||||
srvClose := serveHandler(ctx, logger, nil, pprofAddress, "pprof")
|
||||
defer srvClose()
|
||||
case <-ctx.Done():
|
||||
return
|
||||
}
|
||||
<-ctx.Done() // Prevent defer close until done.
|
||||
}()
|
||||
|
||||
return func() {
|
||||
cancel()
|
||||
<-usr1 // Wait until usr1 is closed, ensures srvClose was run.
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
// agentStartPPROFOnUSR1 is no-op on Windows (no SIGUSR1 signal).
|
||||
func agentStartPPROFOnUSR1(ctx context.Context, logger slog.Logger, pprofAddress string) (srvClose func()) {
|
||||
return func() {}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -43,7 +44,7 @@ func NewWithSubcommands(
|
||||
|
||||
// SetupConfig applies the URL and SessionToken of the client to the config.
|
||||
func SetupConfig(t *testing.T, client *codersdk.Client, root config.Root) {
|
||||
err := root.Session().Write(client.SessionToken)
|
||||
err := root.Session().Write(client.SessionToken())
|
||||
require.NoError(t, err)
|
||||
err = root.URL().Write(client.URL.String())
|
||||
require.NoError(t, err)
|
||||
@@ -55,7 +56,7 @@ func CreateTemplateVersionSource(t *testing.T, responses *echo.Responses) string
|
||||
directory := t.TempDir()
|
||||
f, err := ioutil.TempFile(directory, "*.tf")
|
||||
require.NoError(t, err)
|
||||
f.Close()
|
||||
_ = f.Close()
|
||||
data, err := echo.Tar(responses)
|
||||
require.NoError(t, err)
|
||||
extractTar(t, data, directory)
|
||||
@@ -70,6 +71,9 @@ func extractTar(t *testing.T, data []byte, directory string) {
|
||||
break
|
||||
}
|
||||
require.NoError(t, err)
|
||||
if header.Name == "." || strings.Contains(header.Name, "..") {
|
||||
continue
|
||||
}
|
||||
// #nosec
|
||||
path := filepath.Join(directory, header.Name)
|
||||
mode := header.FileInfo().Mode()
|
||||
|
||||
+64
-25
@@ -35,12 +35,11 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
|
||||
if agent.Status == codersdk.WorkspaceAgentConnected {
|
||||
return nil
|
||||
}
|
||||
if agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
opts.WarnInterval = 0
|
||||
}
|
||||
|
||||
spin := spinner.New(spinner.CharSets[78], 100*time.Millisecond, spinner.WithColor("fgHiGreen"))
|
||||
spin.Writer = writer
|
||||
spin.ForceOutput = true
|
||||
@@ -59,49 +58,89 @@ func Agent(ctx context.Context, writer io.Writer, opts AgentOptions) error {
|
||||
return
|
||||
case <-stopSpin:
|
||||
}
|
||||
cancelFunc()
|
||||
signal.Stop(stopSpin)
|
||||
spin.Stop()
|
||||
// nolint:revive
|
||||
os.Exit(1)
|
||||
}()
|
||||
|
||||
ticker := time.NewTicker(opts.FetchInterval)
|
||||
defer ticker.Stop()
|
||||
timer := time.NewTimer(opts.WarnInterval)
|
||||
defer timer.Stop()
|
||||
var waitMessage string
|
||||
messageAfter := time.NewTimer(opts.WarnInterval)
|
||||
defer messageAfter.Stop()
|
||||
showMessage := func() {
|
||||
resourceMutex.Lock()
|
||||
defer resourceMutex.Unlock()
|
||||
|
||||
m := waitingMessage(agent)
|
||||
if m == waitMessage {
|
||||
return
|
||||
}
|
||||
moveUp := ""
|
||||
if waitMessage != "" {
|
||||
// If this is an update, move a line up
|
||||
// to keep it tidy and aligned.
|
||||
moveUp = "\033[1A"
|
||||
}
|
||||
waitMessage = m
|
||||
|
||||
// Stop the spinner while we write our message.
|
||||
spin.Stop()
|
||||
// Clear the line and (if necessary) move up a line to write our message.
|
||||
_, _ = fmt.Fprintf(writer, "\033[2K%s%s\n\n", moveUp, Styles.Paragraph.Render(Styles.Prompt.String()+waitMessage))
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
default:
|
||||
// Safe to resume operation.
|
||||
spin.Start()
|
||||
}
|
||||
}
|
||||
go func() {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-timer.C:
|
||||
case <-messageAfter.C:
|
||||
messageAfter.Stop()
|
||||
showMessage()
|
||||
}
|
||||
resourceMutex.Lock()
|
||||
defer resourceMutex.Unlock()
|
||||
message := "Don't panic, your workspace is booting up!"
|
||||
if agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
message = "The workspace agent lost connection! Wait for it to reconnect or restart your workspace."
|
||||
}
|
||||
// This saves the cursor position, then defers clearing from the cursor
|
||||
// position to the end of the screen.
|
||||
_, _ = fmt.Fprintf(writer, "\033[s\r\033[2K%s\n\n", Styles.Paragraph.Render(Styles.Prompt.String()+message))
|
||||
defer fmt.Fprintf(writer, "\033[u\033[J")
|
||||
}()
|
||||
|
||||
fetchInterval := time.NewTicker(opts.FetchInterval)
|
||||
defer fetchInterval.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
case <-fetchInterval.C:
|
||||
}
|
||||
resourceMutex.Lock()
|
||||
agent, err = opts.Fetch(ctx)
|
||||
if err != nil {
|
||||
resourceMutex.Unlock()
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
}
|
||||
if agent.Status != codersdk.WorkspaceAgentConnected {
|
||||
resourceMutex.Unlock()
|
||||
continue
|
||||
}
|
||||
resourceMutex.Unlock()
|
||||
return nil
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
return nil
|
||||
case codersdk.WorkspaceAgentTimeout, codersdk.WorkspaceAgentDisconnected:
|
||||
showMessage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitingMessage(agent codersdk.WorkspaceAgent) string {
|
||||
var m string
|
||||
switch agent.Status {
|
||||
case codersdk.WorkspaceAgentTimeout:
|
||||
m = "The workspace agent is having trouble connecting."
|
||||
case codersdk.WorkspaceAgentDisconnected:
|
||||
m = "The workspace agent lost connection!"
|
||||
default:
|
||||
// Not a failure state, no troubleshooting necessary.
|
||||
return "Don't panic, your workspace is booting up!"
|
||||
}
|
||||
if agent.TroubleshootingURL != "" {
|
||||
return fmt.Sprintf("%s See troubleshooting instructions at: %s", m, agent.TroubleshootingURL)
|
||||
}
|
||||
return fmt.Sprintf("%s Wait for it to (re)connect or restart your workspace.", m)
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestAgent(t *testing.T) {
|
||||
@@ -49,3 +50,50 @@ func TestAgent(t *testing.T) {
|
||||
disconnected.Store(true)
|
||||
<-done
|
||||
}
|
||||
|
||||
func TestAgentTimeoutWithTroubleshootingURL(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
||||
wantURL := "https://coder.com/troubleshoot"
|
||||
|
||||
var connected, timeout atomic.Bool
|
||||
cmd := &cobra.Command{
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
err := cliui.Agent(cmd.Context(), cmd.OutOrStdout(), cliui.AgentOptions{
|
||||
WorkspaceName: "example",
|
||||
Fetch: func(ctx context.Context) (codersdk.WorkspaceAgent, error) {
|
||||
agent := codersdk.WorkspaceAgent{
|
||||
Status: codersdk.WorkspaceAgentConnecting,
|
||||
TroubleshootingURL: "https://coder.com/troubleshoot",
|
||||
}
|
||||
switch {
|
||||
case connected.Load():
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
case timeout.Load():
|
||||
agent.Status = codersdk.WorkspaceAgentTimeout
|
||||
}
|
||||
return agent, nil
|
||||
},
|
||||
FetchInterval: time.Millisecond,
|
||||
WarnInterval: 5 * time.Millisecond,
|
||||
})
|
||||
return err
|
||||
},
|
||||
}
|
||||
ptty := ptytest.New(t)
|
||||
cmd.SetOutput(ptty.Output())
|
||||
cmd.SetIn(ptty.Input())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
ptty.ExpectMatch("Don't panic")
|
||||
timeout.Store(true)
|
||||
ptty.ExpectMatch(wantURL)
|
||||
connected.Store(true)
|
||||
<-done
|
||||
}
|
||||
|
||||
@@ -16,14 +16,14 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID, before time.Time) error {
|
||||
func WorkspaceBuild(ctx context.Context, writer io.Writer, client *codersdk.Client, build uuid.UUID) error {
|
||||
return ProvisionerJob(ctx, writer, ProvisionerJobOptions{
|
||||
Fetch: func() (codersdk.ProvisionerJob, error) {
|
||||
build, err := client.WorkspaceBuild(ctx, build)
|
||||
return build.Job, err
|
||||
},
|
||||
Logs: func() (<-chan codersdk.ProvisionerJobLog, io.Closer, error) {
|
||||
return client.WorkspaceBuildLogsAfter(ctx, build, before)
|
||||
return client.WorkspaceBuildLogsAfter(ctx, build, 0)
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -103,7 +103,6 @@ func ProvisionerJob(ctx context.Context, writer io.Writer, opts ProvisionerJobOp
|
||||
}
|
||||
updateStage("Running", *job.StartedAt)
|
||||
}
|
||||
updateJob()
|
||||
|
||||
if opts.Cancel != nil {
|
||||
// Handles ctrl+c to cancel a job.
|
||||
@@ -131,10 +130,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()
|
||||
updateJob()
|
||||
|
||||
logs, closer, err := opts.Logs()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("logs: %w", err)
|
||||
return xerrors.Errorf("begin streaming logs: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
|
||||
|
||||
@@ -127,6 +127,13 @@ func renderAgentStatus(agent codersdk.WorkspaceAgent) string {
|
||||
since := database.Now().Sub(*agent.DisconnectedAt)
|
||||
return Styles.Error.Render("⦾ disconnected") + " " +
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]")
|
||||
case codersdk.WorkspaceAgentTimeout:
|
||||
since := database.Now().Sub(agent.CreatedAt)
|
||||
return fmt.Sprintf(
|
||||
"%s %s",
|
||||
Styles.Warn.Render("⦾ timeout"),
|
||||
Styles.Placeholder.Render("["+strconv.Itoa(int(since.Seconds()))+"s]"),
|
||||
)
|
||||
case codersdk.WorkspaceAgentConnected:
|
||||
return Styles.Keyword.Render("⦿ connected")
|
||||
default:
|
||||
|
||||
+6
-2
@@ -153,10 +153,14 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
// Special type formatting.
|
||||
switch val := v.(type) {
|
||||
case time.Time:
|
||||
v = val.Format(time.Stamp)
|
||||
v = val.Format(time.RFC3339)
|
||||
case *time.Time:
|
||||
if val != nil {
|
||||
v = val.Format(time.Stamp)
|
||||
v = val.Format(time.RFC3339)
|
||||
}
|
||||
case *int64:
|
||||
if val != nil {
|
||||
v = *val
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if val != nil {
|
||||
|
||||
+12
-12
@@ -52,7 +52,7 @@ type tableTest3 struct {
|
||||
func Test_DisplayTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.Local)
|
||||
someTime := time.Date(2022, 8, 2, 15, 49, 10, 0, time.UTC)
|
||||
in := []tableTest1{
|
||||
{
|
||||
Name: "foo",
|
||||
@@ -131,10 +131,10 @@ func Test_DisplayTable(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||
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.
|
||||
@@ -158,10 +158,10 @@ baz 30 [] baz1 31 <nil> <nil> baz3
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } Aug 2 15:49:10 <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } Aug 2 15:49:10 <nil>
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } Aug 2 15:49:10 Aug 2 15:49:10
|
||||
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)
|
||||
@@ -175,9 +175,9 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
|
||||
|
||||
expected := `
|
||||
NAME SUB 1 NAME SUB 3 INNER NAME TIME
|
||||
foo foo1 foo3 Aug 2 15:49:10
|
||||
bar bar1 bar3 Aug 2 15:49:10
|
||||
baz baz1 baz3 Aug 2 15:49:10
|
||||
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"})
|
||||
|
||||
@@ -6,6 +6,10 @@ import (
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
const (
|
||||
FlagName = "global-config"
|
||||
)
|
||||
|
||||
// Root represents the configuration directory.
|
||||
type Root string
|
||||
|
||||
@@ -13,6 +17,11 @@ func (r Root) Session() File {
|
||||
return File(filepath.Join(string(r), "session"))
|
||||
}
|
||||
|
||||
// ReplicaID is a unique identifier for the Coder server.
|
||||
func (r Root) ReplicaID() File {
|
||||
return File(filepath.Join(string(r), "replica_id"))
|
||||
}
|
||||
|
||||
func (r Root) URL() File {
|
||||
return File(filepath.Join(string(r), "url"))
|
||||
}
|
||||
@@ -37,6 +46,10 @@ func (r Root) PostgresPort() File {
|
||||
return File(filepath.Join(r.PostgresPath(), "port"))
|
||||
}
|
||||
|
||||
func (r Root) DeploymentConfigPath() string {
|
||||
return filepath.Join(string(r), "server.yaml")
|
||||
}
|
||||
|
||||
// File provides convenience methods for interacting with *os.File.
|
||||
type File string
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
# Coder Server Configuration
|
||||
|
||||
# Automatically authenticate HTTP(s) Git requests.
|
||||
gitauth:
|
||||
# Supported: azure-devops, bitbucket, github, gitlab
|
||||
# - type: github
|
||||
# client_id: xxxxxx
|
||||
# client_secret: xxxxxx
|
||||
|
||||
# Multiple providers are an Enterprise feature.
|
||||
# Contact sales@coder.com for a license.
|
||||
#
|
||||
# If multiple providers are used, a unique "id"
|
||||
# must be provided for each one.
|
||||
# - id: example
|
||||
# type: azure-devops
|
||||
# client_id: xxxxxxx
|
||||
# client_secret: xxxxxxx
|
||||
# A custom regex can be used to match a specific
|
||||
# repository or organization to limit auth scope.
|
||||
# regex: github.com/coder
|
||||
# Custom authentication and token URLs should be
|
||||
# used for self-managed Git provider deployments.
|
||||
# auth_url: https://example.com/oauth/authorize
|
||||
# token_url: https://example.com/oauth/token
|
||||
+3
-3
@@ -70,7 +70,7 @@ type sshWorkspaceConfig struct {
|
||||
}
|
||||
|
||||
func sshFetchWorkspaceConfigs(ctx context.Context, client *codersdk.Client) ([]sshWorkspaceConfig, error) {
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -78,8 +78,8 @@ func sshFetchWorkspaceConfigs(ctx context.Context, client *codersdk.Client) ([]s
|
||||
}
|
||||
|
||||
var errGroup errgroup.Group
|
||||
workspaceConfigs := make([]sshWorkspaceConfig, len(workspaces))
|
||||
for i, workspace := range workspaces {
|
||||
workspaceConfigs := make([]sshWorkspaceConfig, len(res.Workspaces))
|
||||
for i, workspace := range res.Workspaces {
|
||||
i := i
|
||||
workspace := workspace
|
||||
errGroup.Go(func() error {
|
||||
|
||||
+14
-13
@@ -19,7 +19,6 @@ import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
@@ -29,6 +28,7 @@ import (
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func sshConfigFileName(t *testing.T) (sshConfig string) {
|
||||
@@ -68,7 +68,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: []*proto.Provision_Response{{
|
||||
ProvisionPlan: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -82,7 +82,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}},
|
||||
Provision: []*proto.Provision_Response{{
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -105,17 +105,16 @@ func TestConfigSSH(t *testing.T) {
|
||||
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = authToken
|
||||
agentClient.SetSessionToken(authToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
}()
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
agentConn, err := client.DialWorkspaceAgentTailnet(context.Background(), slog.Logger{}, resources[0].Agents[0].ID)
|
||||
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
agentConn, err := client.DialWorkspaceAgent(context.Background(), resources[0].Agents[0].ID, nil)
|
||||
require.NoError(t, err)
|
||||
defer agentConn.Close()
|
||||
|
||||
@@ -133,7 +132,9 @@ func TestConfigSSH(t *testing.T) {
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
ssh, err := agentConn.SSH()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ssh, err := agentConn.SSH(ctx)
|
||||
cancel()
|
||||
assert.NoError(t, err)
|
||||
wg.Add(2)
|
||||
go func() {
|
||||
@@ -661,9 +662,9 @@ func TestConfigSSH_Hostnames(t *testing.T) {
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
// authToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: provisionResponse,
|
||||
Provision: provisionResponse,
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: provisionResponse,
|
||||
ProvisionApply: provisionResponse,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
+3
-5
@@ -33,7 +33,7 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
organization, err := CurrentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,7 +139,6 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
after := time.Now()
|
||||
workspace, err := client.CreateWorkspace(cmd.Context(), organization.ID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: workspaceName,
|
||||
@@ -151,7 +150,7 @@ func create() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID, after)
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -238,7 +237,6 @@ PromptParamLoop:
|
||||
_, _ = 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,
|
||||
@@ -255,7 +253,7 @@ PromptParamLoop:
|
||||
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)
|
||||
return client.TemplateVersionDryRunLogsAfter(cmd.Context(), templateVersion.ID, dryRun.ID, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
|
||||
+42
-18
@@ -26,9 +26,9 @@ func TestCreate(t *testing.T) {
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: provisionCompleteWithAgent,
|
||||
ProvisionDryRun: provisionCompleteWithAgent,
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: provisionCompleteWithAgent,
|
||||
ProvisionPlan: provisionCompleteWithAgent,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
@@ -144,9 +144,9 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
defaultValue := "something"
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
ProvisionApply: echo.ProvisionComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -185,9 +185,9 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
defaultValue := "something"
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
ProvisionApply: echo.ProvisionComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
@@ -228,17 +228,20 @@ func TestCreate(t *testing.T) {
|
||||
|
||||
defaultValue := "something"
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
Provision: echo.ProvisionComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Parse: createTestParseResponseWithDefault(defaultValue),
|
||||
ProvisionApply: echo.ProvisionComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
})
|
||||
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
_ = 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())
|
||||
_, _ = parameterFile.WriteString("username: \"boingo\"")
|
||||
|
||||
cmd, root := clitest.New(t, "create", "", "--parameter-file", parameterFile.Name())
|
||||
clitest.SetupConfig(t, client, root)
|
||||
doneChan := make(chan struct{})
|
||||
pty := ptytest.New(t)
|
||||
@@ -247,11 +250,32 @@ func TestCreate(t *testing.T) {
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := cmd.Execute()
|
||||
assert.EqualError(t, err, "Parameter value absent in parameter file for \"region\"!")
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
matches := []struct {
|
||||
match string
|
||||
write string
|
||||
}{
|
||||
{
|
||||
match: "Specify a name",
|
||||
write: "my-workspace",
|
||||
},
|
||||
{
|
||||
match: fmt.Sprintf("Enter a value (default: %q):", defaultValue),
|
||||
write: "bingo",
|
||||
},
|
||||
{
|
||||
match: "Confirm create?",
|
||||
write: "yes",
|
||||
},
|
||||
}
|
||||
|
||||
for _, m := range matches {
|
||||
pty.ExpectMatch(m.match)
|
||||
pty.WriteLine(m.write)
|
||||
}
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("FailedDryRun", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
|
||||
@@ -264,7 +288,7 @@ func TestCreate(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}},
|
||||
ProvisionDryRun: []*proto.Provision_Response{
|
||||
ProvisionPlan: []*proto.Provision_Response{
|
||||
{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{},
|
||||
|
||||
+1
-2
@@ -47,7 +47,6 @@ func deleteWorkspace() *cobra.Command {
|
||||
)
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
ProvisionerState: state,
|
||||
@@ -57,7 +56,7 @@ func deleteWorkspace() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,781 @@
|
||||
package deployment
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/buildinfo"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
func newConfig() *codersdk.DeploymentConfig {
|
||||
return &codersdk.DeploymentConfig{
|
||||
AccessURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Access URL",
|
||||
Usage: "External URL to access your deployment. This must be accessible by all provisioned workspaces.",
|
||||
Flag: "access-url",
|
||||
},
|
||||
WildcardAccessURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Wildcard Access URL",
|
||||
Usage: "Specifies the wildcard hostname to use for workspace applications in the form \"*.example.com\".",
|
||||
Flag: "wildcard-access-url",
|
||||
},
|
||||
// DEPRECATED: Use HTTPAddress or TLS.Address instead.
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
Usage: "Bind address of the server.",
|
||||
Flag: "address",
|
||||
Shorthand: "a",
|
||||
// Deprecated, so we don't have a default. If set, it will overwrite
|
||||
// HTTPAddress and TLS.Address and print a warning.
|
||||
Hidden: true,
|
||||
Default: "",
|
||||
},
|
||||
HTTPAddress: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Address",
|
||||
Usage: "HTTP bind address of the server. Unset to disable the HTTP endpoint.",
|
||||
Flag: "http-address",
|
||||
Default: "127.0.0.1:3000",
|
||||
},
|
||||
AutobuildPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Autobuild Poll Interval",
|
||||
Usage: "Interval to poll for scheduled workspace builds.",
|
||||
Flag: "autobuild-poll-interval",
|
||||
Hidden: true,
|
||||
Default: time.Minute,
|
||||
},
|
||||
DERP: &codersdk.DERP{
|
||||
Server: &codersdk.DERPServerConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "DERP Server Enable",
|
||||
Usage: "Whether to enable or disable the embedded DERP relay server.",
|
||||
Flag: "derp-server-enable",
|
||||
Default: true,
|
||||
},
|
||||
RegionID: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "DERP Server Region ID",
|
||||
Usage: "Region ID to use for the embedded DERP server.",
|
||||
Flag: "derp-server-region-id",
|
||||
Default: 999,
|
||||
},
|
||||
RegionCode: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Server Region Code",
|
||||
Usage: "Region code to use for the embedded DERP server.",
|
||||
Flag: "derp-server-region-code",
|
||||
Default: "coder",
|
||||
},
|
||||
RegionName: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Server Region Name",
|
||||
Usage: "Region name that for the embedded DERP server.",
|
||||
Flag: "derp-server-region-name",
|
||||
Default: "Coder Embedded Relay",
|
||||
},
|
||||
STUNAddresses: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "DERP Server STUN Addresses",
|
||||
Usage: "Addresses for STUN servers to establish P2P connections. Set empty to disable P2P connections.",
|
||||
Flag: "derp-server-stun-addresses",
|
||||
Default: []string{"stun.l.google.com:19302"},
|
||||
},
|
||||
RelayURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Server Relay URL",
|
||||
Usage: "An HTTP URL that is accessible by other replicas to relay DERP traffic. Required for high availability.",
|
||||
Flag: "derp-server-relay-url",
|
||||
Enterprise: true,
|
||||
},
|
||||
},
|
||||
Config: &codersdk.DERPConfig{
|
||||
URL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Config URL",
|
||||
Usage: "URL to fetch a DERP mapping on startup. See: https://tailscale.com/kb/1118/custom-derp-servers/",
|
||||
Flag: "derp-config-url",
|
||||
},
|
||||
Path: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "DERP Config Path",
|
||||
Usage: "Path to read a DERP mapping from. See: https://tailscale.com/kb/1118/custom-derp-servers/",
|
||||
Flag: "derp-config-path",
|
||||
},
|
||||
},
|
||||
},
|
||||
GitAuth: &codersdk.DeploymentConfigField[[]codersdk.GitAuthConfig]{
|
||||
Name: "Git Auth",
|
||||
Usage: "Automatically authenticate Git inside workspaces.",
|
||||
Flag: "gitauth",
|
||||
Default: []codersdk.GitAuthConfig{},
|
||||
},
|
||||
Prometheus: &codersdk.PrometheusConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Prometheus Enable",
|
||||
Usage: "Serve prometheus metrics on the address defined by prometheus address.",
|
||||
Flag: "prometheus-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Prometheus Address",
|
||||
Usage: "The bind address to serve prometheus metrics.",
|
||||
Flag: "prometheus-address",
|
||||
Default: "127.0.0.1:2112",
|
||||
},
|
||||
},
|
||||
Pprof: &codersdk.PprofConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Pprof Enable",
|
||||
Usage: "Serve pprof metrics on the address defined by pprof address.",
|
||||
Flag: "pprof-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Pprof Address",
|
||||
Usage: "The bind address to serve pprof.",
|
||||
Flag: "pprof-address",
|
||||
Default: "127.0.0.1:6060",
|
||||
},
|
||||
},
|
||||
ProxyTrustedHeaders: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Proxy Trusted Headers",
|
||||
Flag: "proxy-trusted-headers",
|
||||
Usage: "Headers to trust for forwarding IP addresses. e.g. Cf-Connecting-Ip, True-Client-Ip, X-Forwarded-For",
|
||||
},
|
||||
ProxyTrustedOrigins: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "Proxy Trusted Origins",
|
||||
Flag: "proxy-trusted-origins",
|
||||
Usage: "Origin addresses to respect \"proxy-trusted-headers\". e.g. 192.168.1.0/24",
|
||||
},
|
||||
CacheDirectory: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Cache Directory",
|
||||
Usage: "The directory to cache temporary files. If unspecified and $CACHE_DIRECTORY is set, it will be used for compatibility with systemd.",
|
||||
Flag: "cache-dir",
|
||||
Default: DefaultCacheDir(),
|
||||
},
|
||||
InMemoryDatabase: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "In Memory Database",
|
||||
Usage: "Controls whether data will be stored in an in-memory database.",
|
||||
Flag: "in-memory",
|
||||
Hidden: true,
|
||||
},
|
||||
PostgresURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Postgres Connection URL",
|
||||
Usage: "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\".",
|
||||
Flag: "postgres-url",
|
||||
Secret: true,
|
||||
},
|
||||
OAuth2: &codersdk.OAuth2Config{
|
||||
Github: &codersdk.OAuth2GithubConfig{
|
||||
ClientID: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OAuth2 GitHub Client ID",
|
||||
Usage: "Client ID for Login with GitHub.",
|
||||
Flag: "oauth2-github-client-id",
|
||||
},
|
||||
ClientSecret: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OAuth2 GitHub Client Secret",
|
||||
Usage: "Client secret for Login with GitHub.",
|
||||
Flag: "oauth2-github-client-secret",
|
||||
Secret: true,
|
||||
},
|
||||
AllowedOrgs: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OAuth2 GitHub Allowed Orgs",
|
||||
Usage: "Organizations the user must be a member of to Login with GitHub.",
|
||||
Flag: "oauth2-github-allowed-orgs",
|
||||
},
|
||||
AllowedTeams: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OAuth2 GitHub Allowed Teams",
|
||||
Usage: "Teams inside organizations the user must be a member of to Login with GitHub. Structured as: <organization-name>/<team-slug>.",
|
||||
Flag: "oauth2-github-allowed-teams",
|
||||
},
|
||||
AllowSignups: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OAuth2 GitHub Allow Signups",
|
||||
Usage: "Whether new users can sign up with GitHub.",
|
||||
Flag: "oauth2-github-allow-signups",
|
||||
},
|
||||
AllowEveryone: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OAuth2 GitHub Allow Everyone",
|
||||
Usage: "Allow all logins, setting this option means allowed orgs and teams must be empty.",
|
||||
Flag: "oauth2-github-allow-everyone",
|
||||
},
|
||||
EnterpriseBaseURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OAuth2 GitHub Enterprise Base URL",
|
||||
Usage: "Base URL of a GitHub Enterprise deployment to use for Login with GitHub.",
|
||||
Flag: "oauth2-github-enterprise-base-url",
|
||||
},
|
||||
},
|
||||
},
|
||||
OIDC: &codersdk.OIDCConfig{
|
||||
AllowSignups: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OIDC Allow Signups",
|
||||
Usage: "Whether new users can sign up with OIDC.",
|
||||
Flag: "oidc-allow-signups",
|
||||
Default: true,
|
||||
},
|
||||
ClientID: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Client ID",
|
||||
Usage: "Client ID to use for Login with OIDC.",
|
||||
Flag: "oidc-client-id",
|
||||
},
|
||||
ClientSecret: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Client Secret",
|
||||
Usage: "Client secret to use for Login with OIDC.",
|
||||
Flag: "oidc-client-secret",
|
||||
Secret: true,
|
||||
},
|
||||
EmailDomain: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OIDC Email Domain",
|
||||
Usage: "Email domains that clients logging in with OIDC must match.",
|
||||
Flag: "oidc-email-domain",
|
||||
},
|
||||
IssuerURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Issuer URL",
|
||||
Usage: "Issuer URL to use for Login with OIDC.",
|
||||
Flag: "oidc-issuer-url",
|
||||
},
|
||||
Scopes: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "OIDC Scopes",
|
||||
Usage: "Scopes to grant when authenticating with OIDC.",
|
||||
Flag: "oidc-scopes",
|
||||
Default: []string{oidc.ScopeOpenID, "profile", "email"},
|
||||
},
|
||||
IgnoreEmailVerified: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "OIDC Ignore Email Verified",
|
||||
Usage: "Ignore the email_verified claim from the upstream provider.",
|
||||
Flag: "oidc-ignore-email-verified",
|
||||
Default: false,
|
||||
},
|
||||
UsernameField: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "OIDC Username Field",
|
||||
Usage: "OIDC claim field to use as the username.",
|
||||
Flag: "oidc-username-field",
|
||||
Default: "preferred_username",
|
||||
},
|
||||
},
|
||||
|
||||
Telemetry: &codersdk.TelemetryConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Telemetry Enable",
|
||||
Usage: "Whether telemetry is enabled or not. Coder collects anonymized usage data to help improve our product.",
|
||||
Flag: "telemetry",
|
||||
Default: flag.Lookup("test.v") == nil,
|
||||
},
|
||||
Trace: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Telemetry Trace",
|
||||
Usage: "Whether Opentelemetry traces are sent to Coder. Coder collects anonymized application tracing to help improve our product. Disabling telemetry also disables this option.",
|
||||
Flag: "telemetry-trace",
|
||||
Default: flag.Lookup("test.v") == nil,
|
||||
},
|
||||
URL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Telemetry URL",
|
||||
Usage: "URL to send telemetry.",
|
||||
Flag: "telemetry-url",
|
||||
Hidden: true,
|
||||
Default: "https://telemetry.coder.com",
|
||||
},
|
||||
},
|
||||
TLS: &codersdk.TLSConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "TLS Enable",
|
||||
Usage: "Whether TLS will be enabled.",
|
||||
Flag: "tls-enable",
|
||||
},
|
||||
Address: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Address",
|
||||
Usage: "HTTPS bind address of the server.",
|
||||
Flag: "tls-address",
|
||||
Default: "127.0.0.1:3443",
|
||||
},
|
||||
RedirectHTTP: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Redirect HTTP to HTTPS",
|
||||
Usage: "Whether HTTP requests will be redirected to the access URL (if it's a https URL and TLS is enabled). Requests to local IP addresses are never redirected regardless of this setting.",
|
||||
Flag: "tls-redirect-http-to-https",
|
||||
Default: true,
|
||||
},
|
||||
CertFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Certificate Files",
|
||||
Usage: "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.",
|
||||
Flag: "tls-cert-file",
|
||||
},
|
||||
ClientCAFile: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client CA Files",
|
||||
Usage: "PEM-encoded Certificate Authority file used for checking the authenticity of client",
|
||||
Flag: "tls-client-ca-file",
|
||||
},
|
||||
ClientAuth: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client Auth",
|
||||
Usage: "Policy the server will follow for TLS Client Authentication. Accepted values are \"none\", \"request\", \"require-any\", \"verify-if-given\", or \"require-and-verify\".",
|
||||
Flag: "tls-client-auth",
|
||||
Default: "none",
|
||||
},
|
||||
KeyFiles: &codersdk.DeploymentConfigField[[]string]{
|
||||
Name: "TLS Key Files",
|
||||
Usage: "Paths to the private keys for each of the certificates. It requires a PEM-encoded file.",
|
||||
Flag: "tls-key-file",
|
||||
},
|
||||
MinVersion: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Minimum Version",
|
||||
Usage: "Minimum supported version of TLS. Accepted values are \"tls10\", \"tls11\", \"tls12\" or \"tls13\"",
|
||||
Flag: "tls-min-version",
|
||||
Default: "tls12",
|
||||
},
|
||||
ClientCertFile: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client Cert File",
|
||||
Usage: "Path to certificate for client TLS authentication. It requires a PEM-encoded file.",
|
||||
Flag: "tls-client-cert-file",
|
||||
},
|
||||
ClientKeyFile: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "TLS Client Key File",
|
||||
Usage: "Path to key for client TLS authentication. It requires a PEM-encoded file.",
|
||||
Flag: "tls-client-key-file",
|
||||
},
|
||||
},
|
||||
Trace: &codersdk.TraceConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Trace Enable",
|
||||
Usage: "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md",
|
||||
Flag: "trace",
|
||||
},
|
||||
HoneycombAPIKey: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Trace Honeycomb API Key",
|
||||
Usage: "Enables trace exporting to Honeycomb.io using the provided API Key.",
|
||||
Flag: "trace-honeycomb-api-key",
|
||||
Secret: true,
|
||||
},
|
||||
CaptureLogs: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Capture Logs in Traces",
|
||||
Usage: "Enables capturing of logs as events in traces. This is useful for debugging, but may result in a very large amount of events being sent to the tracing backend which may incur significant costs. If the verbose flag was supplied, debug-level logs will be included.",
|
||||
Flag: "trace-logs",
|
||||
},
|
||||
},
|
||||
SecureAuthCookie: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Secure Auth Cookie",
|
||||
Usage: "Controls if the 'Secure' property is set on browser session cookies.",
|
||||
Flag: "secure-auth-cookie",
|
||||
},
|
||||
SSHKeygenAlgorithm: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "SSH Keygen Algorithm",
|
||||
Usage: "The algorithm to use for generating ssh keys. Accepted values are \"ed25519\", \"ecdsa\", or \"rsa4096\".",
|
||||
Flag: "ssh-keygen-algorithm",
|
||||
Default: "ed25519",
|
||||
},
|
||||
MetricsCacheRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Metrics Cache Refresh Interval",
|
||||
Usage: "How frequently metrics are refreshed",
|
||||
Flag: "metrics-cache-refresh-interval",
|
||||
Hidden: true,
|
||||
Default: time.Hour,
|
||||
},
|
||||
AgentStatRefreshInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Agent Stat Refresh Interval",
|
||||
Usage: "How frequently agent stats are recorded",
|
||||
Flag: "agent-stats-refresh-interval",
|
||||
Hidden: true,
|
||||
Default: 10 * time.Minute,
|
||||
},
|
||||
AgentFallbackTroubleshootingURL: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "Agent Fallback Troubleshooting URL",
|
||||
Usage: "URL to use for agent troubleshooting when not set in the template",
|
||||
Flag: "agent-fallback-troubleshooting-url",
|
||||
Hidden: true,
|
||||
Default: "https://coder.com/docs/coder-oss/latest/templates#troubleshooting-templates",
|
||||
},
|
||||
AuditLogging: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Audit Logging",
|
||||
Usage: "Specifies whether audit logging is enabled.",
|
||||
Flag: "audit-logging",
|
||||
Default: true,
|
||||
Enterprise: true,
|
||||
},
|
||||
BrowserOnly: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Browser Only",
|
||||
Usage: "Whether Coder only allows connections to workspaces via the browser.",
|
||||
Flag: "browser-only",
|
||||
Enterprise: true,
|
||||
},
|
||||
SCIMAPIKey: &codersdk.DeploymentConfigField[string]{
|
||||
Name: "SCIM API Key",
|
||||
Usage: "Enables SCIM and sets the authentication header for the built-in SCIM server. New users are automatically created with OIDC authentication.",
|
||||
Flag: "scim-auth-header",
|
||||
Enterprise: true,
|
||||
Secret: true,
|
||||
},
|
||||
Provisioner: &codersdk.ProvisionerConfig{
|
||||
Daemons: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "Provisioner Daemons",
|
||||
Usage: "Number of provisioner daemons to create on start. If builds are stuck in queued state for a long time, consider increasing this.",
|
||||
Flag: "provisioner-daemons",
|
||||
Default: 3,
|
||||
},
|
||||
DaemonPollInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Poll Interval",
|
||||
Usage: "Time to wait before polling for a new job.",
|
||||
Flag: "provisioner-daemon-poll-interval",
|
||||
Default: time.Second,
|
||||
},
|
||||
DaemonPollJitter: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Poll Jitter",
|
||||
Usage: "Random jitter added to the poll interval.",
|
||||
Flag: "provisioner-daemon-poll-jitter",
|
||||
Default: 100 * time.Millisecond,
|
||||
},
|
||||
ForceCancelInterval: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Force Cancel Interval",
|
||||
Usage: "Time to force cancel provisioning tasks that are stuck.",
|
||||
Flag: "provisioner-force-cancel-interval",
|
||||
Default: 10 * time.Minute,
|
||||
},
|
||||
},
|
||||
RateLimit: &codersdk.RateLimitConfig{
|
||||
DisableAll: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Disable All Rate Limits",
|
||||
Usage: "Disables all rate limits. This is not recommended in production.",
|
||||
Flag: "dangerous-disable-rate-limits",
|
||||
Default: false,
|
||||
},
|
||||
API: &codersdk.DeploymentConfigField[int]{
|
||||
Name: "API Rate Limit",
|
||||
Usage: "Maximum number of requests per minute allowed to the API per user, or per IP address for unauthenticated users. Negative values mean no rate limit. Some API endpoints have separate strict rate limits regardless of this value to prevent denial-of-service or brute force attacks.",
|
||||
// Change the env from the auto-generated CODER_RATE_LIMIT_API to the
|
||||
// old value to avoid breaking existing deployments.
|
||||
EnvOverride: "CODER_API_RATE_LIMIT",
|
||||
Flag: "api-rate-limit",
|
||||
Default: 512,
|
||||
},
|
||||
},
|
||||
Experimental: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Experimental",
|
||||
Usage: "Enable experimental features. Experimental features are not ready for production.",
|
||||
Flag: "experimental",
|
||||
},
|
||||
UpdateCheck: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Update Check",
|
||||
Usage: "Periodically check for new releases of Coder and inform the owner. The check is performed once per day.",
|
||||
Flag: "update-check",
|
||||
Default: flag.Lookup("test.v") == nil && !buildinfo.IsDev(),
|
||||
},
|
||||
MaxTokenLifetime: &codersdk.DeploymentConfigField[time.Duration]{
|
||||
Name: "Max Token Lifetime",
|
||||
Usage: "The maximum lifetime duration for any user creating a token.",
|
||||
Flag: "max-token-lifetime",
|
||||
Default: 24 * 30 * time.Hour,
|
||||
},
|
||||
Swagger: &codersdk.SwaggerConfig{
|
||||
Enable: &codersdk.DeploymentConfigField[bool]{
|
||||
Name: "Enable swagger endpoint",
|
||||
Usage: "Expose the swagger endpoint via /swagger.",
|
||||
Flag: "swagger-enable",
|
||||
Default: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func Config(flagset *pflag.FlagSet, vip *viper.Viper) (*codersdk.DeploymentConfig, error) {
|
||||
dc := newConfig()
|
||||
flg, err := flagset.GetString(config.FlagName)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("get global config from flag: %w", err)
|
||||
}
|
||||
vip.SetEnvPrefix("coder")
|
||||
|
||||
if flg != "" {
|
||||
vip.SetConfigFile(flg + "/server.yaml")
|
||||
err = vip.ReadInConfig()
|
||||
if err != nil && !xerrors.Is(err, os.ErrNotExist) {
|
||||
return dc, xerrors.Errorf("reading deployment config: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
setConfig("", vip, &dc)
|
||||
|
||||
return dc, nil
|
||||
}
|
||||
|
||||
func setConfig(prefix string, vip *viper.Viper, target interface{}) {
|
||||
val := reflect.Indirect(reflect.ValueOf(target))
|
||||
typ := val.Type()
|
||||
if typ.Kind() != reflect.Struct {
|
||||
val = val.Elem()
|
||||
typ = val.Type()
|
||||
}
|
||||
|
||||
// Ensure that we only bind env variables to proper fields,
|
||||
// otherwise Viper will get confused if the parent struct is
|
||||
// assigned a value.
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
value := val.FieldByName("Value").Interface()
|
||||
|
||||
env, ok := val.FieldByName("EnvOverride").Interface().(string)
|
||||
if !ok {
|
||||
panic("DeploymentConfigField[].EnvOverride must be a string")
|
||||
}
|
||||
if env == "" {
|
||||
env = formatEnv(prefix)
|
||||
}
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetString(vip.GetString(prefix))
|
||||
case bool:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetBool(vip.GetBool(prefix))
|
||||
case int:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetInt(int64(vip.GetInt(prefix)))
|
||||
case time.Duration:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
val.FieldByName("Value").SetInt(int64(vip.GetDuration(prefix)))
|
||||
case []string:
|
||||
vip.MustBindEnv(prefix, env)
|
||||
// As of October 21st, 2022 we supported delimiting a string
|
||||
// with a comma, but Viper only supports with a space. This
|
||||
// is a small hack around it!
|
||||
rawSlice := reflect.ValueOf(vip.GetStringSlice(prefix)).Interface()
|
||||
slice, ok := rawSlice.([]string)
|
||||
if !ok {
|
||||
panic(fmt.Sprintf("string slice is of type %T", rawSlice))
|
||||
}
|
||||
value := make([]string, 0, len(slice))
|
||||
for _, entry := range slice {
|
||||
value = append(value, strings.Split(entry, ",")...)
|
||||
}
|
||||
val.FieldByName("Value").Set(reflect.ValueOf(value))
|
||||
case []codersdk.GitAuthConfig:
|
||||
// Do not bind to CODER_GITAUTH, instead bind to CODER_GITAUTH_0_*, etc.
|
||||
values := readSliceFromViper[codersdk.GitAuthConfig](vip, prefix, value)
|
||||
val.FieldByName("Value").Set(reflect.ValueOf(values))
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", value))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fv := val.Field(i)
|
||||
ft := fv.Type()
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
var key string
|
||||
if prefix == "" {
|
||||
key = tag
|
||||
} else {
|
||||
key = fmt.Sprintf("%s.%s", prefix, tag)
|
||||
}
|
||||
switch ft.Kind() {
|
||||
case reflect.Ptr:
|
||||
setConfig(key, vip, fv.Interface())
|
||||
case reflect.Slice:
|
||||
for j := 0; j < fv.Len(); j++ {
|
||||
key := fmt.Sprintf("%s.%d", key, j)
|
||||
setConfig(key, vip, fv.Index(j).Interface())
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// readSliceFromViper reads a typed mapping from the key provided.
|
||||
// This enables environment variables like CODER_GITAUTH_<index>_CLIENT_ID.
|
||||
func readSliceFromViper[T any](vip *viper.Viper, key string, value any) []T {
|
||||
elementType := reflect.TypeOf(value).Elem()
|
||||
returnValues := make([]T, 0)
|
||||
for entry := 0; true; entry++ {
|
||||
// Only create an instance when the entry exists in viper...
|
||||
// otherwise we risk
|
||||
var instance *reflect.Value
|
||||
for i := 0; i < elementType.NumField(); i++ {
|
||||
fve := elementType.Field(i)
|
||||
prop := fve.Tag.Get("json")
|
||||
// For fields that are omitted in JSON, we use a YAML tag.
|
||||
if prop == "-" {
|
||||
prop = fve.Tag.Get("yaml")
|
||||
}
|
||||
configKey := fmt.Sprintf("%s.%d.%s", key, entry, prop)
|
||||
|
||||
// Ensure the env entry for this key is registered
|
||||
// before checking value.
|
||||
//
|
||||
// We don't support DeploymentConfigField[].EnvOverride for array flags so
|
||||
// this is fine to just use `formatEnv` here.
|
||||
vip.MustBindEnv(configKey, formatEnv(configKey))
|
||||
|
||||
value := vip.Get(configKey)
|
||||
if value == nil {
|
||||
continue
|
||||
}
|
||||
if instance == nil {
|
||||
newType := reflect.Indirect(reflect.New(elementType))
|
||||
instance = &newType
|
||||
}
|
||||
switch v := instance.Field(i).Type().String(); v {
|
||||
case "[]string":
|
||||
value = vip.GetStringSlice(configKey)
|
||||
case "bool":
|
||||
value = vip.GetBool(configKey)
|
||||
default:
|
||||
}
|
||||
instance.Field(i).Set(reflect.ValueOf(value))
|
||||
}
|
||||
if instance == nil {
|
||||
break
|
||||
}
|
||||
value, ok := instance.Interface().(T)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
returnValues = append(returnValues, value)
|
||||
}
|
||||
return returnValues
|
||||
}
|
||||
|
||||
func NewViper() *viper.Viper {
|
||||
dc := newConfig()
|
||||
vip := viper.New()
|
||||
vip.SetEnvPrefix("coder")
|
||||
vip.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
|
||||
setViperDefaults("", vip, dc)
|
||||
|
||||
return vip
|
||||
}
|
||||
|
||||
func setViperDefaults(prefix string, vip *viper.Viper, target interface{}) {
|
||||
val := reflect.ValueOf(target).Elem()
|
||||
val = reflect.Indirect(val)
|
||||
typ := val.Type()
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
value := val.FieldByName("Default").Interface()
|
||||
vip.SetDefault(prefix, value)
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fv := val.Field(i)
|
||||
ft := fv.Type()
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
var key string
|
||||
if prefix == "" {
|
||||
key = tag
|
||||
} else {
|
||||
key = fmt.Sprintf("%s.%s", prefix, tag)
|
||||
}
|
||||
switch ft.Kind() {
|
||||
case reflect.Ptr:
|
||||
setViperDefaults(key, vip, fv.Interface())
|
||||
case reflect.Slice:
|
||||
// we currently don't support default values on structured slices
|
||||
continue
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func AttachFlags(flagset *pflag.FlagSet, vip *viper.Viper, enterprise bool) {
|
||||
setFlags("", flagset, vip, newConfig(), enterprise)
|
||||
}
|
||||
|
||||
//nolint:revive
|
||||
func setFlags(prefix string, flagset *pflag.FlagSet, vip *viper.Viper, target interface{}, enterprise bool) {
|
||||
val := reflect.Indirect(reflect.ValueOf(target))
|
||||
typ := val.Type()
|
||||
if strings.HasPrefix(typ.Name(), "DeploymentConfigField[") {
|
||||
isEnt := val.FieldByName("Enterprise").Bool()
|
||||
if enterprise != isEnt {
|
||||
return
|
||||
}
|
||||
flg := val.FieldByName("Flag").String()
|
||||
if flg == "" {
|
||||
return
|
||||
}
|
||||
|
||||
env, ok := val.FieldByName("EnvOverride").Interface().(string)
|
||||
if !ok {
|
||||
panic("DeploymentConfigField[].EnvOverride must be a string")
|
||||
}
|
||||
if env == "" {
|
||||
env = formatEnv(prefix)
|
||||
}
|
||||
|
||||
usage := val.FieldByName("Usage").String()
|
||||
usage = fmt.Sprintf("%s\n%s", usage, cliui.Styles.Placeholder.Render("Consumes $"+env))
|
||||
shorthand := val.FieldByName("Shorthand").String()
|
||||
hidden := val.FieldByName("Hidden").Bool()
|
||||
value := val.FieldByName("Default").Interface()
|
||||
|
||||
// Allow currently set environment variables
|
||||
// to override default values in help output.
|
||||
vip.MustBindEnv(prefix, env)
|
||||
|
||||
switch value.(type) {
|
||||
case string:
|
||||
_ = flagset.StringP(flg, shorthand, vip.GetString(prefix), usage)
|
||||
case bool:
|
||||
_ = flagset.BoolP(flg, shorthand, vip.GetBool(prefix), usage)
|
||||
case int:
|
||||
_ = flagset.IntP(flg, shorthand, vip.GetInt(prefix), usage)
|
||||
case time.Duration:
|
||||
_ = flagset.DurationP(flg, shorthand, vip.GetDuration(prefix), usage)
|
||||
case []string:
|
||||
_ = flagset.StringSliceP(flg, shorthand, vip.GetStringSlice(prefix), usage)
|
||||
case []codersdk.GitAuthConfig:
|
||||
// Ignore this one!
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", typ))
|
||||
}
|
||||
|
||||
_ = vip.BindPFlag(prefix, flagset.Lookup(flg))
|
||||
if hidden {
|
||||
_ = flagset.MarkHidden(flg)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
for i := 0; i < typ.NumField(); i++ {
|
||||
fv := val.Field(i)
|
||||
ft := fv.Type()
|
||||
tag := typ.Field(i).Tag.Get("json")
|
||||
var key string
|
||||
if prefix == "" {
|
||||
key = tag
|
||||
} else {
|
||||
key = fmt.Sprintf("%s.%s", prefix, tag)
|
||||
}
|
||||
switch ft.Kind() {
|
||||
case reflect.Ptr:
|
||||
setFlags(key, flagset, vip, fv.Interface(), enterprise)
|
||||
case reflect.Slice:
|
||||
for j := 0; j < fv.Len(); j++ {
|
||||
key := fmt.Sprintf("%s.%d", key, j)
|
||||
setFlags(key, flagset, vip, fv.Index(j).Interface(), enterprise)
|
||||
}
|
||||
default:
|
||||
panic(fmt.Sprintf("unsupported type %T", ft))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func formatEnv(key string) string {
|
||||
return "CODER_" + strings.ToUpper(strings.NewReplacer("-", "_", ".", "_").Replace(key))
|
||||
}
|
||||
|
||||
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,247 @@
|
||||
package deployment_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/config"
|
||||
"github.com/coder/coder/cli/deployment"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestConfig(t *testing.T) {
|
||||
viper := deployment.NewViper()
|
||||
flagSet := pflag.NewFlagSet("", pflag.ContinueOnError)
|
||||
flagSet.String(config.FlagName, "", "")
|
||||
deployment.AttachFlags(flagSet, viper, true)
|
||||
|
||||
for _, tc := range []struct {
|
||||
Name string
|
||||
Env map[string]string
|
||||
Valid func(config *codersdk.DeploymentConfig)
|
||||
}{{
|
||||
Name: "Deployment",
|
||||
Env: map[string]string{
|
||||
"CODER_ADDRESS": "0.0.0.0:8443",
|
||||
"CODER_ACCESS_URL": "https://dev.coder.com",
|
||||
"CODER_PG_CONNECTION_URL": "some-url",
|
||||
"CODER_PPROF_ADDRESS": "something",
|
||||
"CODER_PPROF_ENABLE": "true",
|
||||
"CODER_PROMETHEUS_ADDRESS": "hello-world",
|
||||
"CODER_PROMETHEUS_ENABLE": "true",
|
||||
"CODER_PROVISIONER_DAEMONS": "5",
|
||||
"CODER_PROVISIONER_DAEMON_POLL_INTERVAL": "5s",
|
||||
"CODER_PROVISIONER_DAEMON_POLL_JITTER": "1s",
|
||||
"CODER_SECURE_AUTH_COOKIE": "true",
|
||||
"CODER_SSH_KEYGEN_ALGORITHM": "potato",
|
||||
"CODER_TELEMETRY": "false",
|
||||
"CODER_TELEMETRY_TRACE": "false",
|
||||
"CODER_WILDCARD_ACCESS_URL": "something-wildcard.com",
|
||||
"CODER_UPDATE_CHECK": "false",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.Address.Value, "0.0.0.0:8443")
|
||||
require.Equal(t, config.AccessURL.Value, "https://dev.coder.com")
|
||||
require.Equal(t, config.PostgresURL.Value, "some-url")
|
||||
require.Equal(t, config.Pprof.Address.Value, "something")
|
||||
require.Equal(t, config.Pprof.Enable.Value, true)
|
||||
require.Equal(t, config.Prometheus.Address.Value, "hello-world")
|
||||
require.Equal(t, config.Prometheus.Enable.Value, true)
|
||||
require.Equal(t, config.Provisioner.Daemons.Value, 5)
|
||||
require.Equal(t, config.Provisioner.DaemonPollInterval.Value, 5*time.Second)
|
||||
require.Equal(t, config.Provisioner.DaemonPollJitter.Value, 1*time.Second)
|
||||
require.Equal(t, config.SecureAuthCookie.Value, true)
|
||||
require.Equal(t, config.SSHKeygenAlgorithm.Value, "potato")
|
||||
require.Equal(t, config.Telemetry.Enable.Value, false)
|
||||
require.Equal(t, config.Telemetry.Trace.Value, false)
|
||||
require.Equal(t, config.WildcardAccessURL.Value, "something-wildcard.com")
|
||||
require.Equal(t, config.UpdateCheck.Value, false)
|
||||
},
|
||||
}, {
|
||||
Name: "DERP",
|
||||
Env: map[string]string{
|
||||
"CODER_DERP_CONFIG_PATH": "/example/path",
|
||||
"CODER_DERP_CONFIG_URL": "https://google.com",
|
||||
"CODER_DERP_SERVER_ENABLE": "false",
|
||||
"CODER_DERP_SERVER_REGION_CODE": "something",
|
||||
"CODER_DERP_SERVER_REGION_ID": "123",
|
||||
"CODER_DERP_SERVER_REGION_NAME": "Code-Land",
|
||||
"CODER_DERP_SERVER_RELAY_URL": "1.1.1.1",
|
||||
"CODER_DERP_SERVER_STUN_ADDRESSES": "google.org",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.DERP.Config.Path.Value, "/example/path")
|
||||
require.Equal(t, config.DERP.Config.URL.Value, "https://google.com")
|
||||
require.Equal(t, config.DERP.Server.Enable.Value, false)
|
||||
require.Equal(t, config.DERP.Server.RegionCode.Value, "something")
|
||||
require.Equal(t, config.DERP.Server.RegionID.Value, 123)
|
||||
require.Equal(t, config.DERP.Server.RegionName.Value, "Code-Land")
|
||||
require.Equal(t, config.DERP.Server.RelayURL.Value, "1.1.1.1")
|
||||
require.Equal(t, config.DERP.Server.STUNAddresses.Value, []string{"google.org"})
|
||||
},
|
||||
}, {
|
||||
Name: "Enterprise",
|
||||
Env: map[string]string{
|
||||
"CODER_AUDIT_LOGGING": "false",
|
||||
"CODER_BROWSER_ONLY": "true",
|
||||
"CODER_SCIM_API_KEY": "some-key",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.AuditLogging.Value, false)
|
||||
require.Equal(t, config.BrowserOnly.Value, true)
|
||||
require.Equal(t, config.SCIMAPIKey.Value, "some-key")
|
||||
},
|
||||
}, {
|
||||
Name: "TLS",
|
||||
Env: map[string]string{
|
||||
"CODER_TLS_CERT_FILE": "/etc/acme-sh/dev.coder.com,/etc/acme-sh/*.dev.coder.com",
|
||||
"CODER_TLS_KEY_FILE": "/etc/acme-sh/dev.coder.com,/etc/acme-sh/*.dev.coder.com",
|
||||
"CODER_TLS_CLIENT_AUTH": "/some/path",
|
||||
"CODER_TLS_CLIENT_CA_FILE": "/some/path",
|
||||
"CODER_TLS_ENABLE": "true",
|
||||
"CODER_TLS_MIN_VERSION": "tls10",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Len(t, config.TLS.CertFiles.Value, 2)
|
||||
require.Equal(t, config.TLS.CertFiles.Value[0], "/etc/acme-sh/dev.coder.com")
|
||||
require.Equal(t, config.TLS.CertFiles.Value[1], "/etc/acme-sh/*.dev.coder.com")
|
||||
|
||||
require.Len(t, config.TLS.KeyFiles.Value, 2)
|
||||
require.Equal(t, config.TLS.KeyFiles.Value[0], "/etc/acme-sh/dev.coder.com")
|
||||
require.Equal(t, config.TLS.KeyFiles.Value[1], "/etc/acme-sh/*.dev.coder.com")
|
||||
|
||||
require.Equal(t, config.TLS.ClientAuth.Value, "/some/path")
|
||||
require.Equal(t, config.TLS.ClientCAFile.Value, "/some/path")
|
||||
require.Equal(t, config.TLS.Enable.Value, true)
|
||||
require.Equal(t, config.TLS.MinVersion.Value, "tls10")
|
||||
},
|
||||
}, {
|
||||
Name: "Trace",
|
||||
Env: map[string]string{
|
||||
"CODER_TRACE_ENABLE": "true",
|
||||
"CODER_TRACE_HONEYCOMB_API_KEY": "my-honeycomb-key",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.Trace.Enable.Value, true)
|
||||
require.Equal(t, config.Trace.HoneycombAPIKey.Value, "my-honeycomb-key")
|
||||
},
|
||||
}, {
|
||||
Name: "OIDC_Defaults",
|
||||
Env: map[string]string{},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Empty(t, config.OIDC.IssuerURL.Value)
|
||||
require.Empty(t, config.OIDC.EmailDomain.Value)
|
||||
require.Empty(t, config.OIDC.ClientID.Value)
|
||||
require.Empty(t, config.OIDC.ClientSecret.Value)
|
||||
require.True(t, config.OIDC.AllowSignups.Value)
|
||||
require.ElementsMatch(t, config.OIDC.Scopes.Value, []string{"openid", "email", "profile"})
|
||||
require.False(t, config.OIDC.IgnoreEmailVerified.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "OIDC",
|
||||
Env: map[string]string{
|
||||
"CODER_OIDC_ISSUER_URL": "https://accounts.google.com",
|
||||
"CODER_OIDC_EMAIL_DOMAIN": "coder.com",
|
||||
"CODER_OIDC_CLIENT_ID": "client",
|
||||
"CODER_OIDC_CLIENT_SECRET": "secret",
|
||||
"CODER_OIDC_ALLOW_SIGNUPS": "false",
|
||||
"CODER_OIDC_SCOPES": "something,here",
|
||||
"CODER_OIDC_IGNORE_EMAIL_VERIFIED": "true",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.OIDC.IssuerURL.Value, "https://accounts.google.com")
|
||||
require.Equal(t, config.OIDC.EmailDomain.Value, []string{"coder.com"})
|
||||
require.Equal(t, config.OIDC.ClientID.Value, "client")
|
||||
require.Equal(t, config.OIDC.ClientSecret.Value, "secret")
|
||||
require.False(t, config.OIDC.AllowSignups.Value)
|
||||
require.Equal(t, config.OIDC.Scopes.Value, []string{"something", "here"})
|
||||
require.True(t, config.OIDC.IgnoreEmailVerified.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "GitHub",
|
||||
Env: map[string]string{
|
||||
"CODER_OAUTH2_GITHUB_CLIENT_ID": "client",
|
||||
"CODER_OAUTH2_GITHUB_CLIENT_SECRET": "secret",
|
||||
"CODER_OAUTH2_GITHUB_ALLOWED_ORGS": "coder",
|
||||
"CODER_OAUTH2_GITHUB_ALLOWED_TEAMS": "coder",
|
||||
"CODER_OAUTH2_GITHUB_ALLOW_SIGNUPS": "true",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.OAuth2.Github.ClientID.Value, "client")
|
||||
require.Equal(t, config.OAuth2.Github.ClientSecret.Value, "secret")
|
||||
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedOrgs.Value)
|
||||
require.Equal(t, []string{"coder"}, config.OAuth2.Github.AllowedTeams.Value)
|
||||
require.Equal(t, config.OAuth2.Github.AllowSignups.Value, true)
|
||||
},
|
||||
}, {
|
||||
Name: "GitAuth",
|
||||
Env: map[string]string{
|
||||
"CODER_GITAUTH_0_ID": "hello",
|
||||
"CODER_GITAUTH_0_TYPE": "github",
|
||||
"CODER_GITAUTH_0_CLIENT_ID": "client",
|
||||
"CODER_GITAUTH_0_CLIENT_SECRET": "secret",
|
||||
"CODER_GITAUTH_0_AUTH_URL": "https://auth.com",
|
||||
"CODER_GITAUTH_0_TOKEN_URL": "https://token.com",
|
||||
"CODER_GITAUTH_0_VALIDATE_URL": "https://validate.com",
|
||||
"CODER_GITAUTH_0_REGEX": "github.com",
|
||||
"CODER_GITAUTH_0_SCOPES": "read write",
|
||||
"CODER_GITAUTH_0_NO_REFRESH": "true",
|
||||
|
||||
"CODER_GITAUTH_1_ID": "another",
|
||||
"CODER_GITAUTH_1_TYPE": "gitlab",
|
||||
"CODER_GITAUTH_1_CLIENT_ID": "client-2",
|
||||
"CODER_GITAUTH_1_CLIENT_SECRET": "secret-2",
|
||||
"CODER_GITAUTH_1_AUTH_URL": "https://auth-2.com",
|
||||
"CODER_GITAUTH_1_TOKEN_URL": "https://token-2.com",
|
||||
"CODER_GITAUTH_1_REGEX": "gitlab.com",
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Len(t, config.GitAuth.Value, 2)
|
||||
require.Equal(t, []codersdk.GitAuthConfig{{
|
||||
ID: "hello",
|
||||
Type: "github",
|
||||
ClientID: "client",
|
||||
ClientSecret: "secret",
|
||||
AuthURL: "https://auth.com",
|
||||
TokenURL: "https://token.com",
|
||||
ValidateURL: "https://validate.com",
|
||||
Regex: "github.com",
|
||||
Scopes: []string{"read", "write"},
|
||||
NoRefresh: true,
|
||||
}, {
|
||||
ID: "another",
|
||||
Type: "gitlab",
|
||||
ClientID: "client-2",
|
||||
ClientSecret: "secret-2",
|
||||
AuthURL: "https://auth-2.com",
|
||||
TokenURL: "https://token-2.com",
|
||||
Regex: "gitlab.com",
|
||||
}}, config.GitAuth.Value)
|
||||
},
|
||||
}, {
|
||||
Name: "Wrong env must not break default values",
|
||||
Env: map[string]string{
|
||||
"CODER_PROMETHEUS_ENABLE": "true",
|
||||
"CODER_PROMETHEUS": "true", // Wrong env name, must not break prom addr.
|
||||
},
|
||||
Valid: func(config *codersdk.DeploymentConfig) {
|
||||
require.Equal(t, config.Prometheus.Enable.Value, true)
|
||||
require.Equal(t, config.Prometheus.Address.Value, config.Prometheus.Address.Default)
|
||||
},
|
||||
}} {
|
||||
tc := tc
|
||||
t.Run(tc.Name, func(t *testing.T) {
|
||||
t.Helper()
|
||||
for key, value := range tc.Env {
|
||||
t.Setenv(key, value)
|
||||
}
|
||||
config, err := deployment.Config(flagSet, viper)
|
||||
require.NoError(t, err)
|
||||
tc.Valid(config)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/retry"
|
||||
)
|
||||
|
||||
// gitAskpass is used by the Coder agent to automatically authenticate
|
||||
// with Git providers based on a hostname.
|
||||
func gitAskpass() *cobra.Command {
|
||||
return &cobra.Command{
|
||||
Use: "gitaskpass",
|
||||
Hidden: true,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
|
||||
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer stop()
|
||||
|
||||
user, host, err := gitauth.ParseAskpass(args[0])
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parse host: %w", err)
|
||||
}
|
||||
|
||||
client, err := createAgentClient(cmd)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create agent client: %w", err)
|
||||
}
|
||||
|
||||
token, err := client.WorkspaceAgentGitAuth(ctx, host, false)
|
||||
if err != nil {
|
||||
var apiError *codersdk.Error
|
||||
if errors.As(err, &apiError) && apiError.StatusCode() == http.StatusNotFound {
|
||||
// This prevents the "Run 'coder --help' for usage"
|
||||
// message from occurring.
|
||||
cmd.Printf("%s\n", apiError.Message)
|
||||
return cliui.Canceled
|
||||
}
|
||||
return xerrors.Errorf("get git token: %w", err)
|
||||
}
|
||||
if token.URL != "" {
|
||||
if err := openURL(cmd, token.URL); err == nil {
|
||||
cmd.Printf("Your browser has been opened to authenticate with Git:\n\n\t%s\n\n", token.URL)
|
||||
} else {
|
||||
cmd.Printf("Open the following URL to authenticate with Git:\n\n\t%s\n\n", token.URL)
|
||||
}
|
||||
|
||||
for r := retry.New(250*time.Millisecond, 10*time.Second); r.Wait(ctx); {
|
||||
token, err = client.WorkspaceAgentGitAuth(ctx, host, true)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
cmd.Printf("You've been authenticated with Git!\n")
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if token.Password != "" {
|
||||
if user == "" {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Password)
|
||||
}
|
||||
} else {
|
||||
fmt.Fprintln(cmd.OutOrStdout(), token.Username)
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"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/httpapi"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
)
|
||||
|
||||
// nolint:paralleltest
|
||||
func TestGitAskpass(t *testing.T) {
|
||||
t.Setenv("GIT_PREFIX", "/")
|
||||
t.Run("UsernameAndPassword", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, codersdk.WorkspaceAgentGitAuthResponse{
|
||||
Username: "something",
|
||||
Password: "bananas",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
cmd, _ := clitest.New(t, "--agent-url", url, "Username for 'https://github.com':")
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
err := cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
pty.ExpectMatch("something")
|
||||
|
||||
cmd, _ = clitest.New(t, "--agent-url", url, "Password for 'https://potato@github.com':")
|
||||
pty = ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
err = cmd.Execute()
|
||||
require.NoError(t, err)
|
||||
pty.ExpectMatch("bananas")
|
||||
})
|
||||
|
||||
t.Run("NoHost", func(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusNotFound, codersdk.Response{
|
||||
Message: "Nope!",
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
err := cmd.Execute()
|
||||
require.ErrorIs(t, err, cliui.Canceled)
|
||||
pty.ExpectMatch("Nope!")
|
||||
})
|
||||
|
||||
t.Run("Poll", func(t *testing.T) {
|
||||
resp := atomic.Pointer[codersdk.WorkspaceAgentGitAuthResponse]{}
|
||||
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
|
||||
URL: "https://something.org",
|
||||
})
|
||||
poll := make(chan struct{}, 10)
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
val := resp.Load()
|
||||
if r.URL.Query().Has("listen") {
|
||||
poll <- struct{}{}
|
||||
if val.URL != "" {
|
||||
httpapi.Write(context.Background(), w, http.StatusInternalServerError, val)
|
||||
return
|
||||
}
|
||||
}
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, val)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
url := srv.URL
|
||||
|
||||
cmd, _ := clitest.New(t, "--agent-url", url, "--no-open", "Username for 'https://github.com':")
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOutput(pty.Output())
|
||||
go func() {
|
||||
err := cmd.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
<-poll
|
||||
resp.Store(&codersdk.WorkspaceAgentGitAuthResponse{
|
||||
Username: "username",
|
||||
Password: "password",
|
||||
})
|
||||
pty.ExpectMatch("username")
|
||||
})
|
||||
}
|
||||
+1
-1
@@ -29,7 +29,7 @@ func gitssh() *cobra.Command {
|
||||
|
||||
// Catch interrupt signals to ensure the temporary private
|
||||
// key file is cleaned up on most cases.
|
||||
ctx, stop := signal.NotifyContext(ctx, interruptSignals...)
|
||||
ctx, stop := signal.NotifyContext(ctx, InterruptSignals...)
|
||||
defer stop()
|
||||
|
||||
// Early check so errors are reported immediately.
|
||||
|
||||
+4
-4
@@ -48,9 +48,9 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
|
||||
// setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -81,7 +81,7 @@ func prepareTestGitSSH(ctx context.Context, t *testing.T) (*codersdk.Client, str
|
||||
errC <- cmd.ExecuteContext(ctx)
|
||||
}()
|
||||
t.Cleanup(func() { require.NoError(t, <-errC) })
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
return agentClient, agentToken, pubkey
|
||||
}
|
||||
|
||||
|
||||
+11
-7
@@ -77,6 +77,7 @@ func list() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filter := codersdk.WorkspaceFilter{
|
||||
FilterQuery: searchQuery,
|
||||
}
|
||||
@@ -91,29 +92,32 @@ func list() *cobra.Command {
|
||||
}
|
||||
filter.Owner = myUser.Username
|
||||
}
|
||||
workspaces, err := client.Workspaces(cmd.Context(), filter)
|
||||
|
||||
res, err := client.Workspaces(cmd.Context(), filter)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
if len(res.Workspaces) == 0 {
|
||||
_, _ = 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{})
|
||||
|
||||
userRes, err := client.Users(cmd.Context(), codersdk.UsersRequest{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
usersByID := map[uuid.UUID]codersdk.User{}
|
||||
for _, user := range users {
|
||||
for _, user := range userRes.Users {
|
||||
usersByID[user.ID] = user
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
displayWorkspaces = make([]workspaceListRow, len(workspaces))
|
||||
for i, workspace := range workspaces {
|
||||
displayWorkspaces = make([]workspaceListRow, len(res.Workspaces))
|
||||
for i, workspace := range res.Workspaces {
|
||||
displayWorkspaces[i] = workspaceListRowFromWorkspace(now, usersByID, workspace)
|
||||
}
|
||||
|
||||
@@ -137,6 +141,6 @@ func list() *cobra.Command {
|
||||
"Specifies whether all workspaces will be listed or not.")
|
||||
cmd.Flags().StringArrayVarP(&columns, "column", "c", nil,
|
||||
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.")
|
||||
cmd.Flags().StringVar(&searchQuery, "search", defaultQuery, "Search for a workspace with a query.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
+46
-13
@@ -38,17 +38,29 @@ func init() {
|
||||
}
|
||||
|
||||
func login() *cobra.Command {
|
||||
const firstUserTrialEnv = "CODER_FIRST_USER_TRIAL"
|
||||
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
password string
|
||||
trial bool
|
||||
)
|
||||
cmd := &cobra.Command{
|
||||
Use: "login <url>",
|
||||
Short: "Authenticate with Coder deployment",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
rawURL := args[0]
|
||||
rawURL := ""
|
||||
if len(args) == 0 {
|
||||
var err error
|
||||
rawURL, err = cmd.Flags().GetString(varURL)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get global url flag")
|
||||
}
|
||||
} else {
|
||||
rawURL = args[0]
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(rawURL, "http://") && !strings.HasPrefix(rawURL, "https://") {
|
||||
scheme := "https"
|
||||
@@ -86,7 +98,7 @@ func login() *cobra.Command {
|
||||
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")
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
if username == "" {
|
||||
if !isTTY(cmd) {
|
||||
@@ -162,11 +174,20 @@ func login() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if !cmd.Flags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
|
||||
v, _ := cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: "Start a 30-day trial of Enterprise?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
trial = v == "yes" || v == "y"
|
||||
}
|
||||
|
||||
_, err = client.CreateFirstUser(cmd.Context(), codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
OrganizationName: username,
|
||||
Password: password,
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: password,
|
||||
Trial: trial,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create initial user: %w", err)
|
||||
@@ -214,7 +235,7 @@ func login() *cobra.Command {
|
||||
Text: "Paste your token here:",
|
||||
Secret: true,
|
||||
Validate: func(token string) error {
|
||||
client.SessionToken = token
|
||||
client.SetSessionToken(token)
|
||||
_, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.New("That's not a valid token!")
|
||||
@@ -228,7 +249,7 @@ func login() *cobra.Command {
|
||||
}
|
||||
|
||||
// Login to get user data - verify it is OK before persisting
|
||||
client.SessionToken = sessionToken
|
||||
client.SetSessionToken(sessionToken)
|
||||
resp, err := client.User(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get user: %w", err)
|
||||
@@ -244,13 +265,14 @@ func login() *cobra.Command {
|
||||
return xerrors.Errorf("write server url: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"Welcome to Coder, %s! You're authenticated.\n", cliui.Styles.Keyword.Render(resp.Username))
|
||||
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.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &trial, "first-user-trial", "", firstUserTrialEnv, false, "Specifies whether a trial license should be provisioned for the Coder deployment or not.")
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -285,5 +307,16 @@ func openURL(cmd *cobra.Command, urlToOpen string) error {
|
||||
return exec.Command("cmd.exe", "/c", "start", strings.ReplaceAll(urlToOpen, "&", "^&")).Start()
|
||||
}
|
||||
|
||||
browserEnv := os.Getenv("BROWSER")
|
||||
if browserEnv != "" {
|
||||
browserSh := fmt.Sprintf("%s '%s'", browserEnv, urlToOpen)
|
||||
cmd := exec.CommandContext(cmd.Context(), "sh", "-c", browserSh)
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to run %v (out: %q): %w", cmd.Args, out, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
return browser.OpenURL(urlToOpen)
|
||||
}
|
||||
|
||||
+42
-7
@@ -56,6 +56,42 @@ func TestLogin(t *testing.T) {
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
"password", "password", // Confirm.
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
value := matches[i+1]
|
||||
pty.ExpectMatch(match)
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "--url", client.URL.String(), "login", "--force-tty")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Execute()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "password",
|
||||
"password", "password", // Confirm.
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -70,11 +106,8 @@ func TestLogin(t *testing.T) {
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
// The --force-tty flag is required on Windows, because the `isatty` library does not
|
||||
// accurately detect Windows ptys when they are not attached to a process:
|
||||
// https://github.com/mattn/go-isatty/issues/59
|
||||
doneChan := make(chan struct{})
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--username", "testuser", "--email", "user@coder.com", "--password", "password")
|
||||
root, _ := clitest.New(t, "login", client.URL.String(), "--first-user-username", "testuser", "--first-user-email", "user@coder.com", "--first-user-password", "password", "--first-user-trial")
|
||||
pty := ptytest.New(t)
|
||||
root.SetIn(pty.Input())
|
||||
root.SetOut(pty.Output())
|
||||
@@ -127,6 +160,8 @@ func TestLogin(t *testing.T) {
|
||||
pty.WriteLine("pass")
|
||||
pty.ExpectMatch("Confirm")
|
||||
pty.WriteLine("pass")
|
||||
pty.ExpectMatch("trial")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
@@ -148,7 +183,7 @@ func TestLogin(t *testing.T) {
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken)
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
})
|
||||
@@ -183,11 +218,11 @@ func TestLogin(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
coderdtest.CreateFirstUser(t, client)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
sessionFile, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, client.SessionToken, sessionFile)
|
||||
require.Equal(t, client.SessionToken(), sessionFile)
|
||||
})
|
||||
}
|
||||
|
||||
+1
-1
@@ -67,7 +67,7 @@ func logout() *cobra.Command {
|
||||
errorString := strings.TrimRight(errorStringBuilder.String(), "\n")
|
||||
return xerrors.New("Failed to log out.\n" + errorString)
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), Caret+"You are no longer logged in. You can log in using 'coder login <url>'.\n")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
+1
-1
@@ -209,7 +209,7 @@ func login(t *testing.T, pty *ptytest.PTY) config.Root {
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Paste your token here:")
|
||||
pty.WriteLine(client.SessionToken)
|
||||
pty.WriteLine(client.SessionToken())
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
|
||||
|
||||
+7
-4
@@ -36,18 +36,21 @@ func createParameterMapFromFile(parameterFile string) (map[string]string, error)
|
||||
return nil, xerrors.Errorf("Parameter file name is not specified")
|
||||
}
|
||||
|
||||
// Returns a parameter value from a given map, if the map exists, else takes input from the user.
|
||||
// Throws an error if the map exists but does not include a value for the parameter.
|
||||
// Returns a parameter value from a given map, if the map does not exist or does not contain the item, it takes input from the user.
|
||||
// Throws an error if there are any errors with the users input.
|
||||
func getParameterValueFromMapOrInput(cmd *cobra.Command, parameterMap map[string]string, parameterSchema codersdk.ParameterSchema) (string, error) {
|
||||
var parameterValue string
|
||||
var err error
|
||||
if parameterMap != nil {
|
||||
var ok bool
|
||||
parameterValue, ok = parameterMap[parameterSchema.Name]
|
||||
if !ok {
|
||||
return "", xerrors.Errorf("Parameter value absent in parameter file for %q!", parameterSchema.Name)
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
parameterValue, err = cliui.ParameterSchema(cmd, parameterSchema)
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -20,6 +20,9 @@ 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(),
|
||||
|
||||
@@ -27,7 +27,7 @@ func parameterList() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
organization, err := currentOrganization(cmd, client)
|
||||
organization, err := CurrentOrganization(cmd, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get current organization: %w", err)
|
||||
}
|
||||
|
||||
+9
-23
@@ -10,13 +10,11 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/pion/udp"
|
||||
"github.com/spf13/cobra"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
@@ -80,7 +78,7 @@ 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(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt)
|
||||
err = cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -96,7 +94,7 @@ func portForward() *cobra.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID)
|
||||
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -139,30 +137,14 @@ func portForward() *cobra.Command {
|
||||
case <-ctx.Done():
|
||||
closeErr = ctx.Err()
|
||||
case <-sigs:
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Received signal, closing all listeners and active connections")
|
||||
closeErr = xerrors.New("signal received")
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "\nReceived signal, closing all listeners and active connections")
|
||||
}
|
||||
|
||||
cancel()
|
||||
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()
|
||||
conn.AwaitReachable(ctx)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Ready!")
|
||||
wg.Wait()
|
||||
return closeErr
|
||||
@@ -214,7 +196,11 @@ func listenAndPortForward(ctx context.Context, cmd *cobra.Command, conn *codersd
|
||||
for {
|
||||
netConn, err := l.Accept()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %+v\n", spec.listenNetwork, spec.listenAddress, err)
|
||||
// Silently ignore net.ErrClosed errors.
|
||||
if xerrors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
}
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStderr(), "Error accepting connection from '%v://%v': %v\n", spec.listenNetwork, spec.listenAddress, err)
|
||||
_, _ = fmt.Fprintln(cmd.OutOrStderr(), "Killing listener")
|
||||
return
|
||||
}
|
||||
|
||||
+9
-11
@@ -114,9 +114,9 @@ func TestPortForward(t *testing.T) {
|
||||
// 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)
|
||||
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
|
||||
@@ -283,7 +283,7 @@ func TestPortForward(t *testing.T) {
|
||||
// runAgent creates a fake workspace and starts an agent locally for that
|
||||
// workspace. The agent will be cleaned up on test completion.
|
||||
// nolint:unused
|
||||
func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]codersdk.WorkspaceResource, codersdk.Workspace) {
|
||||
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")
|
||||
@@ -293,9 +293,9 @@ func runAgent(t *testing.T, client *codersdk.Client, userID uuid.UUID) ([]coders
|
||||
// Setup template
|
||||
agentToken := uuid.NewString()
|
||||
version := coderdtest.CreateTemplateVersion(t, client, orgID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
@@ -336,11 +336,9 @@ 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.
|
||||
|
||||
+2
-5
@@ -16,10 +16,6 @@ func rename() *cobra.Command {
|
||||
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 {
|
||||
@@ -31,8 +27,9 @@ func rename() *cobra.Command {
|
||||
}
|
||||
|
||||
_, _ = 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)."),
|
||||
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). Please backup any data before proceeding."),
|
||||
)
|
||||
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "See: %s\n\n", "https://coder.com/docs/coder-oss/latest/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls")
|
||||
_, err = cliui.Prompt(cmd, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
|
||||
Validate: func(s string) error {
|
||||
|
||||
+3
-1
@@ -27,7 +27,9 @@ func TestRename(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
want := workspace.Name + "-test"
|
||||
// Only append one letter because it's easy to exceed maximum length:
|
||||
// E.g. "compassionate-chandrasekhar82" + "t".
|
||||
want := workspace.Name + "t"
|
||||
cmd, root := clitest.New(t, "rename", workspace.Name, want, "--yes")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
|
||||
@@ -40,7 +40,8 @@ func TestResetPassword(t *testing.T) {
|
||||
serverDone := make(chan struct{})
|
||||
serverCmd, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--postgres-url", connectionURL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -59,10 +60,9 @@ func TestResetPassword(t *testing.T) {
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: oldPassword,
|
||||
OrganizationName: "example",
|
||||
Email: email,
|
||||
Username: username,
|
||||
Password: oldPassword,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
|
||||
+186
-60
@@ -4,10 +4,15 @@ import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"syscall"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
@@ -22,12 +27,14 @@ 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/coderd/gitauth"
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
var (
|
||||
caret = cliui.Styles.Prompt.String()
|
||||
Caret = cliui.Styles.Prompt.String()
|
||||
|
||||
// Applied as annotations to workspace commands
|
||||
// so they display in a separated "help" section.
|
||||
@@ -41,7 +48,6 @@ const (
|
||||
varToken = "token"
|
||||
varAgentToken = "agent-token"
|
||||
varAgentURL = "agent-url"
|
||||
varGlobalConfig = "global-config"
|
||||
varHeader = "header"
|
||||
varNoOpen = "no-open"
|
||||
varNoVersionCheck = "no-version-warning"
|
||||
@@ -52,11 +58,12 @@ const (
|
||||
|
||||
envNoVersionCheck = "CODER_NO_VERSION_WARNING"
|
||||
envNoFeatureWarning = "CODER_NO_FEATURE_WARNING"
|
||||
envSessionToken = "CODER_SESSION_TOKEN"
|
||||
envURL = "CODER_URL"
|
||||
)
|
||||
|
||||
var (
|
||||
errUnauthenticated = xerrors.New(notLoggedInMessage)
|
||||
envSessionToken = "CODER_SESSION_TOKEN"
|
||||
)
|
||||
|
||||
func init() {
|
||||
@@ -65,6 +72,7 @@ func init() {
|
||||
}
|
||||
|
||||
func Core() []*cobra.Command {
|
||||
// Please re-sort this list alphabetically if you change it!
|
||||
return []*cobra.Command{
|
||||
configSSH(),
|
||||
create(),
|
||||
@@ -77,80 +85,61 @@ func Core() []*cobra.Command {
|
||||
parameters(),
|
||||
portForward(),
|
||||
publickey(),
|
||||
rename(),
|
||||
resetPassword(),
|
||||
scaletest(),
|
||||
schedules(),
|
||||
show(),
|
||||
ssh(),
|
||||
speedtest(),
|
||||
ssh(),
|
||||
start(),
|
||||
state(),
|
||||
stop(),
|
||||
rename(),
|
||||
templates(),
|
||||
tokens(),
|
||||
update(),
|
||||
users(),
|
||||
versionCmd(),
|
||||
vscodeSSH(),
|
||||
workspaceAgent(),
|
||||
}
|
||||
}
|
||||
|
||||
func AGPL() []*cobra.Command {
|
||||
all := append(Core(), Server(func(_ context.Context, o *coderd.Options) (*coderd.API, error) {
|
||||
return coderd.New(o), nil
|
||||
all := append(Core(), Server(deployment.NewViper(), func(_ context.Context, o *coderd.Options) (*coderd.API, io.Closer, error) {
|
||||
api := coderd.New(o)
|
||||
return api, api, nil
|
||||
}))
|
||||
return all
|
||||
}
|
||||
|
||||
func Root(subcommands []*cobra.Command) *cobra.Command {
|
||||
// The GIT_ASKPASS environment variable must point at
|
||||
// a binary with no arguments. To prevent writing
|
||||
// cross-platform scripts to invoke the Coder binary
|
||||
// with a `gitaskpass` subcommand, we override the entrypoint
|
||||
// to check if the command was invoked.
|
||||
isGitAskpass := false
|
||||
|
||||
fmtLong := `Coder %s — A tool for provisioning self-hosted development environments with Terraform.
|
||||
`
|
||||
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
|
||||
Long: fmt.Sprintf(fmtLong, buildinfo.Version()),
|
||||
Args: func(cmd *cobra.Command, args []string) error {
|
||||
if gitauth.CheckCommand(args, os.Environ()) {
|
||||
isGitAskpass = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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())
|
||||
return cobra.NoArgs(cmd, args)
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if isGitAskpass {
|
||||
return gitAskpass().RunE(cmd, args)
|
||||
}
|
||||
return cmd.Help()
|
||||
},
|
||||
Example: formatExamples(
|
||||
example{
|
||||
@@ -169,7 +158,7 @@ func Root(subcommands []*cobra.Command) *cobra.Command {
|
||||
|
||||
cmd.SetUsageTemplate(usageTemplate())
|
||||
|
||||
cmd.PersistentFlags().String(varURL, "", "URL to a deployment.")
|
||||
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))
|
||||
@@ -177,7 +166,7 @@ func Root(subcommands []*cobra.Command) *cobra.Command {
|
||||
_ = cmd.PersistentFlags().MarkHidden(varAgentToken)
|
||||
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"), "Path to the global `coder` config directory.")
|
||||
cliflag.String(cmd.PersistentFlags(), config.FlagName, "", "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)
|
||||
@@ -275,7 +264,38 @@ func CreateClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client.SessionToken = token
|
||||
client.SetSessionToken(token)
|
||||
|
||||
// We send these requests in parallel to minimize latency.
|
||||
var (
|
||||
versionErr = make(chan error)
|
||||
warningErr = make(chan error)
|
||||
)
|
||||
go func() {
|
||||
versionErr <- checkVersions(cmd, client)
|
||||
close(versionErr)
|
||||
}()
|
||||
|
||||
go func() {
|
||||
warningErr <- checkWarnings(cmd, client)
|
||||
close(warningErr)
|
||||
}()
|
||||
|
||||
if err = <-versionErr; 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())
|
||||
}
|
||||
|
||||
if err = <-warningErr; err != nil {
|
||||
// Same as above
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(),
|
||||
cliui.Styles.Warn.Render("check entitlement warnings error: %s"), err)
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr())
|
||||
}
|
||||
|
||||
return client, nil
|
||||
}
|
||||
|
||||
@@ -316,12 +336,12 @@ func createAgentClient(cmd *cobra.Command) (*codersdk.Client, error) {
|
||||
return nil, err
|
||||
}
|
||||
client := codersdk.New(serverURL)
|
||||
client.SessionToken = token
|
||||
client.SetSessionToken(token)
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// currentOrganization returns the currently active organization for the authenticated user.
|
||||
func currentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) {
|
||||
// CurrentOrganization returns the currently active organization for the authenticated user.
|
||||
func CurrentOrganization(cmd *cobra.Command, client *codersdk.Client) (codersdk.Organization, error) {
|
||||
orgs, err := client.OrganizationsByUser(cmd.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return codersdk.Organization{}, nil
|
||||
@@ -354,7 +374,7 @@ func namedWorkspace(cmd *cobra.Command, client *codersdk.Client, identifier stri
|
||||
|
||||
// createConfig consumes the global configuration flag to produce a config root.
|
||||
func createConfig(cmd *cobra.Command) config.Root {
|
||||
globalRoot, err := cmd.Flags().GetString(varGlobalConfig)
|
||||
globalRoot, err := cmd.Flags().GetString(config.FlagName)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
@@ -383,6 +403,17 @@ func isTTY(cmd *cobra.Command) bool {
|
||||
// This accepts a reader to work with Cobra's "OutOrStdout"
|
||||
// function for simple testing.
|
||||
func isTTYOut(cmd *cobra.Command) bool {
|
||||
return isTTYWriter(cmd, cmd.OutOrStdout)
|
||||
}
|
||||
|
||||
// isTTYErr returns whether the passed reader is a TTY or not.
|
||||
// This accepts a reader to work with Cobra's "ErrOrStderr"
|
||||
// function for simple testing.
|
||||
func isTTYErr(cmd *cobra.Command) bool {
|
||||
return isTTYWriter(cmd, cmd.ErrOrStderr)
|
||||
}
|
||||
|
||||
func isTTYWriter(cmd *cobra.Command, writer func() io.Writer) bool {
|
||||
// If the `--force-tty` command is available, and set,
|
||||
// assume we're in a tty. This is primarily for cases on Windows
|
||||
// where we may not be able to reliably detect this automatically (ie, tests)
|
||||
@@ -390,7 +421,7 @@ func isTTYOut(cmd *cobra.Command) bool {
|
||||
if forceTty && err == nil {
|
||||
return true
|
||||
}
|
||||
file, ok := cmd.OutOrStdout().(*os.File)
|
||||
file, ok := writer().(*os.File)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
@@ -557,12 +588,17 @@ func checkVersions(cmd *cobra.Command, client *codersdk.Client) error {
|
||||
}
|
||||
|
||||
fmtWarningText := `version mismatch: client %s, server %s
|
||||
download the server version with: 'curl -L https://coder.com/install.sh | sh -s -- --version %s'
|
||||
`
|
||||
// Our installation script doesn't work on Windows, so instead we direct the user
|
||||
// to the GitHub release page to download the latest installer.
|
||||
if runtime.GOOS == "windows" {
|
||||
fmtWarningText += `download the server version from: https://github.com/coder/coder/releases/v%s`
|
||||
} else {
|
||||
fmtWarningText += `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())
|
||||
}
|
||||
@@ -598,3 +634,93 @@ func (h *headerTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
}
|
||||
return h.transport.RoundTrip(req)
|
||||
}
|
||||
|
||||
// dumpHandler provides a custom SIGQUIT and SIGTRAP handler that dumps the
|
||||
// stacktrace of all goroutines to stderr and a well-known file in the home
|
||||
// directory. This is useful for debugging deadlock issues that may occur in
|
||||
// production in workspaces, since the default Go runtime will only dump to
|
||||
// stderr (which is often difficult/impossible to read in a workspace).
|
||||
//
|
||||
// SIGQUITs will still cause the program to exit (similarly to the default Go
|
||||
// runtime behavior).
|
||||
//
|
||||
// A SIGQUIT handler will not be registered if GOTRACEBACK=crash.
|
||||
//
|
||||
// On Windows this immediately returns.
|
||||
func dumpHandler(ctx context.Context) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// free up the goroutine since it'll be permanently blocked anyways
|
||||
return
|
||||
}
|
||||
|
||||
listenSignals := []os.Signal{syscall.SIGTRAP}
|
||||
if os.Getenv("GOTRACEBACK") != "crash" {
|
||||
listenSignals = append(listenSignals, syscall.SIGQUIT)
|
||||
}
|
||||
|
||||
sigs := make(chan os.Signal, 1)
|
||||
signal.Notify(sigs, listenSignals...)
|
||||
defer signal.Stop(sigs)
|
||||
|
||||
for {
|
||||
sigStr := ""
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case sig := <-sigs:
|
||||
switch sig {
|
||||
case syscall.SIGQUIT:
|
||||
sigStr = "SIGQUIT"
|
||||
case syscall.SIGTRAP:
|
||||
sigStr = "SIGTRAP"
|
||||
}
|
||||
}
|
||||
|
||||
// Start with a 1MB buffer and keep doubling it until we can fit the
|
||||
// entire stacktrace, stopping early once we reach 64MB.
|
||||
buf := make([]byte, 1_000_000)
|
||||
stacklen := 0
|
||||
for {
|
||||
stacklen = runtime.Stack(buf, true)
|
||||
if stacklen < len(buf) {
|
||||
break
|
||||
}
|
||||
if 2*len(buf) > 64_000_000 {
|
||||
// Write a message to the end of the buffer saying that it was
|
||||
// truncated.
|
||||
const truncatedMsg = "\n\n\nstack trace truncated due to size\n"
|
||||
copy(buf[len(buf)-len(truncatedMsg):], truncatedMsg)
|
||||
break
|
||||
}
|
||||
buf = make([]byte, 2*len(buf))
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(os.Stderr, "%s:\n%s\n", sigStr, buf[:stacklen])
|
||||
|
||||
// Write to a well-known file.
|
||||
dir, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
dir = os.TempDir()
|
||||
}
|
||||
fpath := filepath.Join(dir, fmt.Sprintf("coder-agent-%s.dump", time.Now().Format("2006-01-02T15:04:05.000Z")))
|
||||
_, _ = fmt.Fprintf(os.Stderr, "writing dump to %q\n", fpath)
|
||||
|
||||
f, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "failed to open dump file: %v\n", err.Error())
|
||||
goto done
|
||||
}
|
||||
_, err = f.Write(buf[:stacklen])
|
||||
_ = f.Close()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "failed to write dump file: %v\n", err.Error())
|
||||
goto done
|
||||
}
|
||||
|
||||
done:
|
||||
if sigStr == "SIGQUIT" {
|
||||
//nolint:revive
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,14 @@ package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -15,8 +21,127 @@ import (
|
||||
"github.com/coder/coder/cli"
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
// To update the golden files:
|
||||
// make update-golden-files
|
||||
var updateGoldenFiles = flag.Bool("update", false, "update .golden files")
|
||||
|
||||
//nolint:tparallel,paralleltest // These test sets env vars.
|
||||
func TestCommandHelp(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
commonEnv := map[string]string{
|
||||
"CODER_CONFIG_DIR": "/tmp/coder-cli-test-config",
|
||||
}
|
||||
|
||||
type testCase struct {
|
||||
name string
|
||||
cmd []string
|
||||
env map[string]string
|
||||
}
|
||||
tests := []testCase{
|
||||
{
|
||||
name: "coder --help",
|
||||
cmd: []string{"--help"},
|
||||
},
|
||||
{
|
||||
name: "coder server --help",
|
||||
cmd: []string{"server", "--help"},
|
||||
env: map[string]string{
|
||||
"CODER_CACHE_DIRECTORY": "/tmp/coder-cli-test-cache",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
root := cli.Root(cli.AGPL())
|
||||
ExtractCommandPathsLoop:
|
||||
for _, cp := range extractVisibleCommandPaths(nil, root.Commands()) {
|
||||
name := fmt.Sprintf("coder %s --help", strings.Join(cp, " "))
|
||||
cmd := append(cp, "--help")
|
||||
for _, tt := range tests {
|
||||
if tt.name == name {
|
||||
continue ExtractCommandPathsLoop
|
||||
}
|
||||
}
|
||||
tests = append(tests, testCase{name: name, cmd: cmd})
|
||||
}
|
||||
|
||||
wd, err := os.Getwd()
|
||||
require.NoError(t, err)
|
||||
if runtime.GOOS == "windows" {
|
||||
wd = strings.ReplaceAll(wd, "\\", "\\\\")
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
env := make(map[string]string)
|
||||
for k, v := range commonEnv {
|
||||
env[k] = v
|
||||
}
|
||||
for k, v := range tt.env {
|
||||
env[k] = v
|
||||
}
|
||||
|
||||
// Unset all CODER_ environment variables for a clean slate.
|
||||
for _, kv := range os.Environ() {
|
||||
name := strings.Split(kv, "=")[0]
|
||||
if _, ok := env[name]; !ok && strings.HasPrefix(name, "CODER_") {
|
||||
t.Setenv(name, "")
|
||||
}
|
||||
}
|
||||
// Override environment variables for a reproducible test.
|
||||
for k, v := range env {
|
||||
t.Setenv(k, v)
|
||||
}
|
||||
|
||||
ctx, _ := testutil.Context(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
root, _ := clitest.New(t, tt.cmd...)
|
||||
root.SetOut(&buf)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
got := buf.Bytes()
|
||||
// Remove CRLF newlines (Windows).
|
||||
got = bytes.ReplaceAll(got, []byte{'\r', '\n'}, []byte{'\n'})
|
||||
|
||||
// The `coder templates create --help` command prints the path
|
||||
// to the working directory (--directory flag default value).
|
||||
got = bytes.ReplaceAll(got, []byte(wd), []byte("/tmp/coder-cli-test-workdir"))
|
||||
|
||||
gf := filepath.Join("testdata", strings.Replace(tt.name, " ", "_", -1)+".golden")
|
||||
if *updateGoldenFiles {
|
||||
t.Logf("update golden file for: %q: %s", tt.name, gf)
|
||||
err = os.WriteFile(gf, got, 0o600)
|
||||
require.NoError(t, err, "update golden file")
|
||||
}
|
||||
|
||||
want, err := os.ReadFile(gf)
|
||||
require.NoError(t, err, "read golden file, run \"make update-golden-files\" and commit the changes")
|
||||
// Remove CRLF newlines (Windows).
|
||||
want = bytes.ReplaceAll(want, []byte{'\r', '\n'}, []byte{'\n'})
|
||||
require.Equal(t, string(want), string(got), "golden file mismatch: %s, run \"make update-golden-files\", verify and commit the changes", gf)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractVisibleCommandPaths(cmdPath []string, cmds []*cobra.Command) [][]string {
|
||||
var cmdPaths [][]string
|
||||
for _, c := range cmds {
|
||||
if c.Hidden {
|
||||
continue
|
||||
}
|
||||
cmdPath := append(cmdPath, c.Name())
|
||||
cmdPaths = append(cmdPaths, cmdPath)
|
||||
cmdPaths = append(cmdPaths, extractVisibleCommandPaths(cmdPath, c.Commands())...)
|
||||
}
|
||||
return cmdPaths
|
||||
}
|
||||
|
||||
func TestRoot(t *testing.T) {
|
||||
t.Parallel()
|
||||
t.Run("FormatCobraError", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,866 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/spf13/cobra"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/httpapi"
|
||||
"github.com/coder/coder/coderd/tracing"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/cryptorand"
|
||||
"github.com/coder/coder/scaletest/agentconn"
|
||||
"github.com/coder/coder/scaletest/createworkspaces"
|
||||
"github.com/coder/coder/scaletest/harness"
|
||||
"github.com/coder/coder/scaletest/reconnectingpty"
|
||||
"github.com/coder/coder/scaletest/workspacebuild"
|
||||
)
|
||||
|
||||
const scaletestTracerName = "coder_scaletest"
|
||||
|
||||
func scaletest() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "scaletest",
|
||||
Short: "Run a scale test against the Coder API",
|
||||
Long: "Perform scale tests against the Coder server.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(
|
||||
scaletestCleanup(),
|
||||
scaletestCreateWorkspaces(),
|
||||
)
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
type scaletestTracingFlags struct {
|
||||
traceEnable bool
|
||||
traceCoder bool
|
||||
traceHoneycombAPIKey string
|
||||
tracePropagate bool
|
||||
}
|
||||
|
||||
func (s *scaletestTracingFlags) attach(cmd *cobra.Command) {
|
||||
cliflag.BoolVarP(cmd.Flags(), &s.traceEnable, "trace", "", "CODER_LOADTEST_TRACE", false, "Whether application tracing data is collected. It exports to a backend configured by environment variables. See: https://github.com/open-telemetry/opentelemetry-specification/blob/main/specification/protocol/exporter.md")
|
||||
cliflag.BoolVarP(cmd.Flags(), &s.traceCoder, "trace-coder", "", "CODER_LOADTEST_TRACE_CODER", false, "Whether opentelemetry traces are sent to Coder. We recommend keeping this disabled unless we advise you to enable it.")
|
||||
cliflag.StringVarP(cmd.Flags(), &s.traceHoneycombAPIKey, "trace-honeycomb-api-key", "", "CODER_LOADTEST_TRACE_HONEYCOMB_API_KEY", "", "Enables trace exporting to Honeycomb.io using the provided API key.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &s.tracePropagate, "trace-propagate", "", "CODER_LOADTEST_TRACE_PROPAGATE", false, "Enables trace propagation to the Coder backend, which will be used to correlate server-side spans with client-side spans. Only enable this if the server is configured with the exact same tracing configuration as the client.")
|
||||
}
|
||||
|
||||
// provider returns a trace.TracerProvider, a close function and a bool showing
|
||||
// whether tracing is enabled or not.
|
||||
func (s *scaletestTracingFlags) provider(ctx context.Context) (trace.TracerProvider, func(context.Context) error, bool, error) {
|
||||
shouldTrace := s.traceEnable || s.traceCoder || s.traceHoneycombAPIKey != ""
|
||||
if !shouldTrace {
|
||||
tracerProvider := trace.NewNoopTracerProvider()
|
||||
return tracerProvider, func(_ context.Context) error { return nil }, false, nil
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, err := tracing.TracerProvider(ctx, scaletestTracerName, tracing.TracerOpts{
|
||||
Default: s.traceEnable,
|
||||
Coder: s.traceCoder,
|
||||
Honeycomb: s.traceHoneycombAPIKey,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, false, xerrors.Errorf("initialize tracing: %w", err)
|
||||
}
|
||||
|
||||
var closeTracingOnce sync.Once
|
||||
return tracerProvider, func(ctx context.Context) error {
|
||||
var err error
|
||||
closeTracingOnce.Do(func() {
|
||||
err = closeTracing(ctx)
|
||||
})
|
||||
|
||||
return err
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
type scaletestStrategyFlags struct {
|
||||
cleanup bool
|
||||
concurrency int
|
||||
timeout time.Duration
|
||||
timeoutPerJob time.Duration
|
||||
}
|
||||
|
||||
func (s *scaletestStrategyFlags) attach(cmd *cobra.Command) {
|
||||
concurrencyLong, concurrencyEnv, concurrencyDescription := "concurrency", "CODER_LOADTEST_CONCURRENCY", "Number of concurrent jobs to run. 0 means unlimited."
|
||||
timeoutLong, timeoutEnv, timeoutDescription := "timeout", "CODER_LOADTEST_TIMEOUT", "Timeout for the entire test run. 0 means unlimited."
|
||||
jobTimeoutLong, jobTimeoutEnv, jobTimeoutDescription := "job-timeout", "CODER_LOADTEST_JOB_TIMEOUT", "Timeout per job. Jobs may take longer to complete under higher concurrency limits."
|
||||
if s.cleanup {
|
||||
concurrencyLong, concurrencyEnv, concurrencyDescription = "cleanup-"+concurrencyLong, "CODER_LOADTEST_CLEANUP_CONCURRENCY", strings.ReplaceAll(concurrencyDescription, "jobs", "cleanup jobs")
|
||||
timeoutLong, timeoutEnv, timeoutDescription = "cleanup-"+timeoutLong, "CODER_LOADTEST_CLEANUP_TIMEOUT", strings.ReplaceAll(timeoutDescription, "test", "cleanup")
|
||||
jobTimeoutLong, jobTimeoutEnv, jobTimeoutDescription = "cleanup-"+jobTimeoutLong, "CODER_LOADTEST_CLEANUP_JOB_TIMEOUT", strings.ReplaceAll(jobTimeoutDescription, "jobs", "cleanup jobs")
|
||||
}
|
||||
|
||||
cliflag.IntVarP(cmd.Flags(), &s.concurrency, concurrencyLong, "", concurrencyEnv, 1, concurrencyDescription)
|
||||
cliflag.DurationVarP(cmd.Flags(), &s.timeout, timeoutLong, "", timeoutEnv, 30*time.Minute, timeoutDescription)
|
||||
cliflag.DurationVarP(cmd.Flags(), &s.timeoutPerJob, jobTimeoutLong, "", jobTimeoutEnv, 5*time.Minute, jobTimeoutDescription)
|
||||
}
|
||||
|
||||
func (s *scaletestStrategyFlags) toStrategy() harness.ExecutionStrategy {
|
||||
var strategy harness.ExecutionStrategy
|
||||
if s.concurrency == 1 {
|
||||
strategy = harness.LinearExecutionStrategy{}
|
||||
} else if s.concurrency == 0 {
|
||||
strategy = harness.ConcurrentExecutionStrategy{}
|
||||
} else {
|
||||
strategy = harness.ParallelExecutionStrategy{
|
||||
Limit: s.concurrency,
|
||||
}
|
||||
}
|
||||
|
||||
if s.timeoutPerJob > 0 {
|
||||
strategy = harness.TimeoutExecutionStrategyWrapper{
|
||||
Timeout: s.timeoutPerJob,
|
||||
Inner: strategy,
|
||||
}
|
||||
}
|
||||
|
||||
return strategy
|
||||
}
|
||||
|
||||
func (s *scaletestStrategyFlags) toContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
if s.timeout > 0 {
|
||||
return context.WithTimeout(ctx, s.timeout)
|
||||
}
|
||||
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
|
||||
type scaleTestOutputFormat string
|
||||
|
||||
const (
|
||||
scaleTestOutputFormatText scaleTestOutputFormat = "text"
|
||||
scaleTestOutputFormatJSON scaleTestOutputFormat = "json"
|
||||
// TODO: html format
|
||||
)
|
||||
|
||||
type scaleTestOutput struct {
|
||||
format scaleTestOutputFormat
|
||||
// Zero or one (the first) path will have the path set to "-" to indicate
|
||||
// stdout.
|
||||
path string
|
||||
}
|
||||
|
||||
func (o *scaleTestOutput) write(res harness.Results, stdout io.Writer) error {
|
||||
var (
|
||||
w = stdout
|
||||
c io.Closer
|
||||
)
|
||||
if o.path != "-" {
|
||||
f, err := os.Create(o.path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create output file: %w", err)
|
||||
}
|
||||
w, c = f, f
|
||||
}
|
||||
|
||||
switch o.format {
|
||||
case scaleTestOutputFormatText:
|
||||
res.PrintText(w)
|
||||
case scaleTestOutputFormatJSON:
|
||||
err := json.NewEncoder(w).Encode(res)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("encode JSON: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Sync the file to disk if it's a file.
|
||||
if s, ok := w.(interface{ Sync() error }); ok {
|
||||
err := s.Sync()
|
||||
// On Linux, EINVAL is returned when calling fsync on /dev/stdout. We
|
||||
// can safely ignore this error.
|
||||
if err != nil && !xerrors.Is(err, syscall.EINVAL) {
|
||||
return xerrors.Errorf("flush output file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if c != nil {
|
||||
err := c.Close()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("close output file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type scaletestOutputFlags struct {
|
||||
outputSpecs []string
|
||||
}
|
||||
|
||||
func (s *scaletestOutputFlags) attach(cmd *cobra.Command) {
|
||||
cliflag.StringArrayVarP(cmd.Flags(), &s.outputSpecs, "output", "", "CODER_SCALETEST_OUTPUTS", []string{"text"}, `Output format specs in the format "<format>[:<path>]". Not specifying a path will default to stdout. Available formats: text, json.`)
|
||||
}
|
||||
|
||||
func (s *scaletestOutputFlags) parse() ([]scaleTestOutput, error) {
|
||||
var stdoutFormat scaleTestOutputFormat
|
||||
|
||||
validFormats := map[scaleTestOutputFormat]struct{}{
|
||||
scaleTestOutputFormatText: {},
|
||||
scaleTestOutputFormatJSON: {},
|
||||
}
|
||||
|
||||
var out []scaleTestOutput
|
||||
for i, o := range s.outputSpecs {
|
||||
parts := strings.SplitN(o, ":", 2)
|
||||
format := scaleTestOutputFormat(parts[0])
|
||||
if _, ok := validFormats[format]; !ok {
|
||||
return nil, xerrors.Errorf("invalid output format %q in output flag %d", parts[0], i)
|
||||
}
|
||||
|
||||
if len(parts) == 1 {
|
||||
if stdoutFormat != "" {
|
||||
return nil, xerrors.Errorf("multiple output flags specified for stdout")
|
||||
}
|
||||
stdoutFormat = format
|
||||
continue
|
||||
}
|
||||
if len(parts) != 2 {
|
||||
return nil, xerrors.Errorf("invalid output flag %d: %q", i, o)
|
||||
}
|
||||
|
||||
out = append(out, scaleTestOutput{
|
||||
format: format,
|
||||
path: parts[1],
|
||||
})
|
||||
}
|
||||
|
||||
// Default to --output text
|
||||
if stdoutFormat == "" && len(out) == 0 {
|
||||
stdoutFormat = scaleTestOutputFormatText
|
||||
}
|
||||
|
||||
if stdoutFormat != "" {
|
||||
out = append([]scaleTestOutput{{
|
||||
format: stdoutFormat,
|
||||
path: "-",
|
||||
}}, out...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func requireAdmin(ctx context.Context, client *codersdk.Client) (codersdk.User, error) {
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return codersdk.User{}, xerrors.Errorf("fetch current user: %w", err)
|
||||
}
|
||||
|
||||
// Only owners can do scaletests. This isn't a very strong check but there's
|
||||
// not much else we can do. Ratelimits are enforced for non-owners so
|
||||
// hopefully that limits the damage if someone disables this check and runs
|
||||
// it against a non-owner account on a production deployment.
|
||||
ok := false
|
||||
for _, role := range me.Roles {
|
||||
if role.Name == "owner" {
|
||||
ok = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !ok {
|
||||
return me, xerrors.Errorf("Not logged in as a site owner. Scale testing is only available to site owners.")
|
||||
}
|
||||
|
||||
return me, nil
|
||||
}
|
||||
|
||||
// userCleanupRunner is a runner that deletes a user in the Run phase.
|
||||
type userCleanupRunner struct {
|
||||
client *codersdk.Client
|
||||
userID uuid.UUID
|
||||
}
|
||||
|
||||
var _ harness.Runnable = &userCleanupRunner{}
|
||||
|
||||
// Run implements Runnable.
|
||||
func (r *userCleanupRunner) Run(ctx context.Context, _ string, _ io.Writer) error {
|
||||
if r.userID == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
ctx, span := tracing.StartSpan(ctx)
|
||||
defer span.End()
|
||||
|
||||
err := r.client.DeleteUser(ctx, r.userID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("delete user %q: %w", r.userID, err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func scaletestCleanup() *cobra.Command {
|
||||
var (
|
||||
cleanupStrategy = &scaletestStrategyFlags{cleanup: true}
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "cleanup",
|
||||
Short: "Cleanup any orphaned scaletest resources",
|
||||
Long: "Cleanup scaletest workspaces, then cleanup scaletest users. The strategy flags will apply to each stage of the cleanup process.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.BypassRatelimits = true
|
||||
|
||||
cmd.PrintErrln("Fetching scaletest workspaces...")
|
||||
var (
|
||||
pageNumber = 0
|
||||
limit = 100
|
||||
workspaces []codersdk.Workspace
|
||||
)
|
||||
for {
|
||||
page, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: "scaletest-",
|
||||
Offset: pageNumber * limit,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch scaletest workspaces page %d: %w", pageNumber, err)
|
||||
}
|
||||
|
||||
pageNumber++
|
||||
if len(page.Workspaces) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pageWorkspaces := make([]codersdk.Workspace, 0, len(page.Workspaces))
|
||||
for _, w := range page.Workspaces {
|
||||
if isScaleTestWorkspace(w) {
|
||||
pageWorkspaces = append(pageWorkspaces, w)
|
||||
}
|
||||
}
|
||||
workspaces = append(workspaces, pageWorkspaces...)
|
||||
}
|
||||
|
||||
cmd.PrintErrf("Found %d scaletest workspaces\n", len(workspaces))
|
||||
if len(workspaces) != 0 {
|
||||
cmd.Println("Deleting scaletest workspaces...")
|
||||
harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{})
|
||||
|
||||
for i, w := range workspaces {
|
||||
const testName = "cleanup-workspace"
|
||||
r := workspacebuild.NewCleanupRunner(client, w.ID)
|
||||
harness.AddRun(testName, strconv.Itoa(i), r)
|
||||
}
|
||||
|
||||
ctx, cancel := cleanupStrategy.toContext(ctx)
|
||||
defer cancel()
|
||||
err := harness.Run(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness to delete workspaces (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
cmd.Println("Done deleting scaletest workspaces:")
|
||||
res := harness.Results()
|
||||
res.PrintText(cmd.ErrOrStderr())
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.Errorf("failed to delete scaletest workspaces")
|
||||
}
|
||||
}
|
||||
|
||||
cmd.PrintErrln("Fetching scaletest users...")
|
||||
pageNumber = 0
|
||||
limit = 100
|
||||
var users []codersdk.User
|
||||
for {
|
||||
page, err := client.Users(ctx, codersdk.UsersRequest{
|
||||
Search: "scaletest-",
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: pageNumber * limit,
|
||||
Limit: limit,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch scaletest users page %d: %w", pageNumber, err)
|
||||
}
|
||||
|
||||
pageNumber++
|
||||
if len(page.Users) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
pageUsers := make([]codersdk.User, 0, len(page.Users))
|
||||
for _, u := range page.Users {
|
||||
if isScaleTestUser(u) {
|
||||
pageUsers = append(pageUsers, u)
|
||||
}
|
||||
}
|
||||
users = append(users, pageUsers...)
|
||||
}
|
||||
|
||||
cmd.PrintErrf("Found %d scaletest users\n", len(users))
|
||||
if len(workspaces) != 0 {
|
||||
cmd.Println("Deleting scaletest users...")
|
||||
harness := harness.NewTestHarness(cleanupStrategy.toStrategy(), harness.ConcurrentExecutionStrategy{})
|
||||
|
||||
for i, u := range users {
|
||||
const testName = "cleanup-users"
|
||||
r := &userCleanupRunner{
|
||||
client: client,
|
||||
userID: u.ID,
|
||||
}
|
||||
harness.AddRun(testName, strconv.Itoa(i), r)
|
||||
}
|
||||
|
||||
ctx, cancel := cleanupStrategy.toContext(ctx)
|
||||
defer cancel()
|
||||
err := harness.Run(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness to delete users (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
cmd.Println("Done deleting scaletest users:")
|
||||
res := harness.Results()
|
||||
res.PrintText(cmd.ErrOrStderr())
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.Errorf("failed to delete scaletest users")
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cleanupStrategy.attach(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func scaletestCreateWorkspaces() *cobra.Command {
|
||||
var (
|
||||
count int
|
||||
template string
|
||||
parametersFile string
|
||||
parameters []string // key=value
|
||||
|
||||
noPlan bool
|
||||
noCleanup bool
|
||||
// TODO: implement this flag
|
||||
// noCleanupFailures bool
|
||||
noWaitForAgents bool
|
||||
|
||||
runCommand string
|
||||
runTimeout time.Duration
|
||||
runExpectTimeout bool
|
||||
runExpectOutput string
|
||||
runLogOutput bool
|
||||
|
||||
// TODO: customizable agent, currently defaults to the first agent found
|
||||
// if there are multiple
|
||||
connectURL string // http://localhost:4/
|
||||
connectMode string // derp or direct
|
||||
connectHold time.Duration
|
||||
connectInterval time.Duration
|
||||
connectTimeout time.Duration
|
||||
|
||||
tracingFlags = &scaletestTracingFlags{}
|
||||
strategy = &scaletestStrategyFlags{}
|
||||
cleanupStrategy = &scaletestStrategyFlags{cleanup: true}
|
||||
output = &scaletestOutputFlags{}
|
||||
)
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "create-workspaces",
|
||||
Short: "Creates many workspaces and waits for them to be ready",
|
||||
Long: `Creates many users, then creates a workspace for each user and waits for them finish building and fully come online. Optionally runs a command inside each workspace, and connects to the workspace over WireGuard.
|
||||
|
||||
It is recommended that all rate limits are disabled on the server before running this scaletest. This test generates many login events which will be rate limited against the (most likely single) IP.`,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ctx := cmd.Context()
|
||||
client, err := CreateClient(cmd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
me, err := requireAdmin(ctx, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
client.BypassRatelimits = true
|
||||
|
||||
if count <= 0 {
|
||||
return xerrors.Errorf("--count is required and must be greater than 0")
|
||||
}
|
||||
outputs, err := output.parse()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not parse --output flags")
|
||||
}
|
||||
|
||||
var tpl codersdk.Template
|
||||
if template == "" {
|
||||
return xerrors.Errorf("--template is required")
|
||||
}
|
||||
if id, err := uuid.Parse(template); err == nil && id != uuid.Nil {
|
||||
tpl, err = client.Template(ctx, id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by ID %q: %w", template, err)
|
||||
}
|
||||
} else {
|
||||
// List templates in all orgs until we find a match.
|
||||
orgLoop:
|
||||
for _, orgID := range me.OrganizationIDs {
|
||||
tpls, err := client.TemplatesByOrganization(ctx, orgID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("list templates in org %q: %w", orgID, err)
|
||||
}
|
||||
|
||||
for _, t := range tpls {
|
||||
if t.Name == template {
|
||||
tpl = t
|
||||
break orgLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tpl.ID == uuid.Nil {
|
||||
return xerrors.Errorf("could not find template %q in any organization", template)
|
||||
}
|
||||
templateVersion, err := client.TemplateVersion(ctx, tpl.ActiveVersionID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version %q: %w", tpl.ActiveVersionID, err)
|
||||
}
|
||||
|
||||
parameterSchemas, err := client.TemplateVersionSchema(ctx, templateVersion.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template version schema %q: %w", templateVersion.ID, err)
|
||||
}
|
||||
|
||||
paramsMap := map[string]string{}
|
||||
if parametersFile != "" {
|
||||
fileMap, err := createParameterMapFromFile(parametersFile)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("read parameters file %q: %w", parametersFile, err)
|
||||
}
|
||||
|
||||
paramsMap = fileMap
|
||||
}
|
||||
|
||||
for _, p := range parameters {
|
||||
parts := strings.SplitN(p, "=", 2)
|
||||
if len(parts) != 2 {
|
||||
return xerrors.Errorf("invalid parameter %q", p)
|
||||
}
|
||||
|
||||
paramsMap[strings.TrimSpace(parts[0])] = strings.TrimSpace(parts[1])
|
||||
}
|
||||
|
||||
params := []codersdk.CreateParameterRequest{}
|
||||
for _, p := range parameterSchemas {
|
||||
value, ok := paramsMap[p.Name]
|
||||
if !ok {
|
||||
value = ""
|
||||
}
|
||||
|
||||
params = append(params, codersdk.CreateParameterRequest{
|
||||
Name: p.Name,
|
||||
SourceValue: value,
|
||||
SourceScheme: codersdk.ParameterSourceSchemeData,
|
||||
DestinationScheme: p.DefaultDestinationScheme,
|
||||
})
|
||||
}
|
||||
|
||||
// Do a dry-run to ensure the template and parameters are valid
|
||||
// before we start creating users and workspaces.
|
||||
if !noPlan {
|
||||
dryRun, err := client.CreateTemplateVersionDryRun(ctx, templateVersion.ID, codersdk.CreateTemplateVersionDryRunRequest{
|
||||
WorkspaceName: "scaletest",
|
||||
ParameterValues: params,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("start dry run workspace creation: %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, 0)
|
||||
},
|
||||
// Don't show log output for the dry-run unless there's an error.
|
||||
Silent: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("dry-run workspace: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tracerProvider, closeTracing, tracingEnabled, err := tracingFlags.provider(ctx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create tracer provider: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
// Allow time for traces to flush even if command context is
|
||||
// canceled.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
_ = closeTracing(ctx)
|
||||
}()
|
||||
tracer := tracerProvider.Tracer(scaletestTracerName)
|
||||
|
||||
th := harness.NewTestHarness(strategy.toStrategy(), cleanupStrategy.toStrategy())
|
||||
for i := 0; i < count; i++ {
|
||||
const name = "workspacebuild"
|
||||
id := strconv.Itoa(i)
|
||||
|
||||
username, email, err := newScaleTestUser(id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create scaletest username and email: %w", err)
|
||||
}
|
||||
workspaceName, err := newScaleTestWorkspace(id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create scaletest workspace name: %w", err)
|
||||
}
|
||||
|
||||
config := createworkspaces.Config{
|
||||
User: createworkspaces.UserConfig{
|
||||
// TODO: configurable org
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
Username: username,
|
||||
Email: email,
|
||||
},
|
||||
Workspace: workspacebuild.Config{
|
||||
OrganizationID: me.OrganizationIDs[0],
|
||||
// UserID is set by the test automatically.
|
||||
Request: codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: tpl.ID,
|
||||
Name: workspaceName,
|
||||
ParameterValues: params,
|
||||
},
|
||||
NoWaitForAgents: noWaitForAgents,
|
||||
},
|
||||
NoCleanup: noCleanup,
|
||||
}
|
||||
|
||||
if runCommand != "" {
|
||||
config.ReconnectingPTY = &reconnectingpty.Config{
|
||||
// AgentID is set by the test automatically.
|
||||
Init: codersdk.ReconnectingPTYInit{
|
||||
ID: uuid.Nil,
|
||||
Height: 24,
|
||||
Width: 80,
|
||||
Command: runCommand,
|
||||
},
|
||||
Timeout: httpapi.Duration(runTimeout),
|
||||
ExpectTimeout: runExpectTimeout,
|
||||
ExpectOutput: runExpectOutput,
|
||||
LogOutput: runLogOutput,
|
||||
}
|
||||
}
|
||||
if connectURL != "" {
|
||||
config.AgentConn = &agentconn.Config{
|
||||
// AgentID is set by the test automatically.
|
||||
// The ConnectionMode gets validated by the Validate()
|
||||
// call below.
|
||||
ConnectionMode: agentconn.ConnectionMode(connectMode),
|
||||
HoldDuration: httpapi.Duration(connectHold),
|
||||
Connections: []agentconn.Connection{
|
||||
{
|
||||
URL: connectURL,
|
||||
Interval: httpapi.Duration(connectInterval),
|
||||
Timeout: httpapi.Duration(connectTimeout),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
err = config.Validate()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("validate config: %w", err)
|
||||
}
|
||||
|
||||
var runner harness.Runnable = createworkspaces.NewRunner(client, config)
|
||||
if tracingEnabled {
|
||||
runner = &runnableTraceWrapper{
|
||||
tracer: tracer,
|
||||
spanName: fmt.Sprintf("%s/%s", name, id),
|
||||
runner: runner,
|
||||
}
|
||||
}
|
||||
|
||||
th.AddRun(name, id, runner)
|
||||
}
|
||||
|
||||
// TODO: live progress output
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Running load test...")
|
||||
testCtx, testCancel := strategy.toContext(ctx)
|
||||
defer testCancel()
|
||||
err = th.Run(testCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run test harness (harness failure, not a test failure): %w", err)
|
||||
}
|
||||
|
||||
res := th.Results()
|
||||
for _, o := range outputs {
|
||||
err = o.write(res, cmd.OutOrStdout())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write output %q to %q: %w", o.format, o.path, err)
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nCleaning up...")
|
||||
cleanupCtx, cleanupCancel := cleanupStrategy.toContext(ctx)
|
||||
defer cleanupCancel()
|
||||
err = th.Cleanup(cleanupCtx)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("cleanup tests: %w", err)
|
||||
}
|
||||
|
||||
// Upload traces.
|
||||
if tracingEnabled {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "\nUploading traces...")
|
||||
ctx, cancel := context.WithTimeout(ctx, 1*time.Minute)
|
||||
defer cancel()
|
||||
err := closeTracing(ctx)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "\nError uploading traces: %+v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
if res.TotalFail > 0 {
|
||||
return xerrors.New("load test failed, see above for more details")
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cliflag.IntVarP(cmd.Flags(), &count, "count", "c", "CODER_LOADTEST_COUNT", 1, "Required: Number of workspaces to create.")
|
||||
cliflag.StringVarP(cmd.Flags(), &template, "template", "t", "CODER_LOADTEST_TEMPLATE", "", "Required: Name or ID of the template to use for workspaces.")
|
||||
cliflag.StringVarP(cmd.Flags(), ¶metersFile, "parameters-file", "", "CODER_LOADTEST_PARAMETERS_FILE", "", "Path to a YAML file containing the parameters to use for each workspace.")
|
||||
cliflag.StringArrayVarP(cmd.Flags(), ¶meters, "parameter", "", "CODER_LOADTEST_PARAMETERS", []string{}, "Parameters to use for each workspace. Can be specified multiple times. Overrides any existing parameters with the same name from --parameters-file. Format: key=value")
|
||||
|
||||
cliflag.BoolVarP(cmd.Flags(), &noPlan, "no-plan", "", "CODER_LOADTEST_NO_PLAN", false, "Skip the dry-run step to plan the workspace creation. This step ensures that the given parameters are valid for the given template.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noCleanup, "no-cleanup", "", "CODER_LOADTEST_NO_CLEANUP", false, "Do not clean up resources after the test completes. You can cleanup manually using `coder scaletest cleanup`.")
|
||||
// cliflag.BoolVarP(cmd.Flags(), &noCleanupFailures, "no-cleanup-failures", "", "CODER_LOADTEST_NO_CLEANUP_FAILURES", false, "Do not clean up resources from failed jobs to aid in debugging failures. You can cleanup manually using `coder scaletest cleanup`.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &noWaitForAgents, "no-wait-for-agents", "", "CODER_LOADTEST_NO_WAIT_FOR_AGENTS", false, "Do not wait for agents to start before marking the test as succeeded. This can be useful if you are running the test against a template that does not start the agent quickly.")
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &runCommand, "run-command", "", "CODER_LOADTEST_RUN_COMMAND", "", "Command to run inside each workspace using reconnecting-pty (i.e. web terminal protocol). If not specified, no command will be run.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &runTimeout, "run-timeout", "", "CODER_LOADTEST_RUN_TIMEOUT", 5*time.Second, "Timeout for the command to complete.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &runExpectTimeout, "run-expect-timeout", "", "CODER_LOADTEST_RUN_EXPECT_TIMEOUT", false, "Expect the command to timeout. If the command does not finish within the given --run-timeout, it will be marked as succeeded. If the command finishes before the timeout, it will be marked as failed.")
|
||||
cliflag.StringVarP(cmd.Flags(), &runExpectOutput, "run-expect-output", "", "CODER_LOADTEST_RUN_EXPECT_OUTPUT", "", "Expect the command to output the given string (on a single line). If the command does not output the given string, it will be marked as failed.")
|
||||
cliflag.BoolVarP(cmd.Flags(), &runLogOutput, "run-log-output", "", "CODER_LOADTEST_RUN_LOG_OUTPUT", false, "Log the output of the command to the test logs. This should be left off unless you expect small amounts of output. Large amounts of output will cause high memory usage.")
|
||||
|
||||
cliflag.StringVarP(cmd.Flags(), &connectURL, "connect-url", "", "CODER_LOADTEST_CONNECT_URL", "", "URL to connect to inside the the workspace over WireGuard. If not specified, no connections will be made over WireGuard.")
|
||||
cliflag.StringVarP(cmd.Flags(), &connectMode, "connect-mode", "", "CODER_LOADTEST_CONNECT_MODE", "derp", "Mode to use for connecting to the workspace. Can be 'derp' or 'direct'.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &connectHold, "connect-hold", "", "CODER_LOADTEST_CONNECT_HOLD", 30*time.Second, "How long to hold the WireGuard connection open for.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &connectInterval, "connect-interval", "", "CODER_LOADTEST_CONNECT_INTERVAL", time.Second, "How long to wait between making requests to the --connect-url once the connection is established.")
|
||||
cliflag.DurationVarP(cmd.Flags(), &connectTimeout, "connect-timeout", "", "CODER_LOADTEST_CONNECT_TIMEOUT", 5*time.Second, "Timeout for each request to the --connect-url.")
|
||||
|
||||
tracingFlags.attach(cmd)
|
||||
strategy.attach(cmd)
|
||||
cleanupStrategy.attach(cmd)
|
||||
output.attach(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
type runnableTraceWrapper struct {
|
||||
tracer trace.Tracer
|
||||
spanName string
|
||||
runner harness.Runnable
|
||||
|
||||
span trace.Span
|
||||
}
|
||||
|
||||
var _ harness.Runnable = &runnableTraceWrapper{}
|
||||
var _ harness.Cleanable = &runnableTraceWrapper{}
|
||||
|
||||
func (r *runnableTraceWrapper) Run(ctx context.Context, id string, logs io.Writer) error {
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName, trace.WithNewRoot())
|
||||
defer span.End()
|
||||
r.span = span
|
||||
|
||||
traceID := "unknown trace ID"
|
||||
spanID := "unknown span ID"
|
||||
if span.SpanContext().HasTraceID() {
|
||||
traceID = span.SpanContext().TraceID().String()
|
||||
}
|
||||
if span.SpanContext().HasSpanID() {
|
||||
spanID = span.SpanContext().SpanID().String()
|
||||
}
|
||||
_, _ = fmt.Fprintf(logs, "Trace ID: %s\n", traceID)
|
||||
_, _ = fmt.Fprintf(logs, "Span ID: %s\n\n", spanID)
|
||||
|
||||
// Make a separate span for the run itself so the sub-spans are grouped
|
||||
// neatly. The cleanup span is also a child of the above span so this is
|
||||
// important for readability.
|
||||
ctx2, span2 := r.tracer.Start(ctx, r.spanName+" run")
|
||||
defer span2.End()
|
||||
return r.runner.Run(ctx2, id, logs)
|
||||
}
|
||||
|
||||
func (r *runnableTraceWrapper) Cleanup(ctx context.Context, id string) error {
|
||||
c, ok := r.runner.(harness.Cleanable)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
|
||||
if r.span != nil {
|
||||
ctx = trace.ContextWithSpanContext(ctx, r.span.SpanContext())
|
||||
}
|
||||
ctx, span := r.tracer.Start(ctx, r.spanName+" cleanup")
|
||||
defer span.End()
|
||||
|
||||
return c.Cleanup(ctx, id)
|
||||
}
|
||||
|
||||
// newScaleTestUser returns a random username and email address that can be used
|
||||
// for scale testing. The returned username is prefixed with "scaletest-" and
|
||||
// the returned email address is suffixed with "@scaletest.local".
|
||||
func newScaleTestUser(id string) (username string, email string, err error) {
|
||||
randStr, err := cryptorand.String(8)
|
||||
return fmt.Sprintf("scaletest-%s-%s", randStr, id), fmt.Sprintf("%s-%s@scaletest.local", randStr, id), err
|
||||
}
|
||||
|
||||
// newScaleTestWorkspace returns a random workspace name that can be used for
|
||||
// scale testing. The returned workspace name is prefixed with "scaletest-" and
|
||||
// suffixed with the given id.
|
||||
func newScaleTestWorkspace(id string) (name string, err error) {
|
||||
randStr, err := cryptorand.String(8)
|
||||
return fmt.Sprintf("scaletest-%s-%s", randStr, id), err
|
||||
}
|
||||
|
||||
func isScaleTestUser(user codersdk.User) bool {
|
||||
return strings.HasSuffix(user.Email, "@scaletest.local")
|
||||
}
|
||||
|
||||
func isScaleTestWorkspace(workspace codersdk.Workspace) bool {
|
||||
if !strings.HasPrefix(workspace.OwnerName, "scaletest-") {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.HasPrefix(workspace.Name, "scaletest-")
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/cli/clitest"
|
||||
"github.com/coder/coder/coderd/coderdtest"
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/scaletest/harness"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func TestScaleTest(t *testing.T) {
|
||||
t.Skipf("This test is flakey. See https://github.com/coder/coder/issues/4942")
|
||||
t.Parallel()
|
||||
|
||||
// This test does a create-workspaces scale test with --no-cleanup, checks
|
||||
// that the created resources are OK, and then runs a cleanup.
|
||||
t.Run("WorkspaceBuildNoCleanup", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
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)
|
||||
|
||||
// Write a parameters file.
|
||||
tDir := t.TempDir()
|
||||
paramsFile := filepath.Join(tDir, "params.yaml")
|
||||
outputFile := filepath.Join(tDir, "output.json")
|
||||
|
||||
f, err := os.Create(paramsFile)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(`---
|
||||
param1: foo
|
||||
param2: true
|
||||
param3: 1
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
err = f.Close()
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd, root := clitest.New(t, "scaletest", "create-workspaces",
|
||||
"--count", "2",
|
||||
"--template", template.Name,
|
||||
"--parameters-file", paramsFile,
|
||||
"--parameter", "param1=bar",
|
||||
"--parameter", "param4=baz",
|
||||
"--no-cleanup",
|
||||
// This flag is important for tests because agents will never be
|
||||
// started.
|
||||
"--no-wait-for-agents",
|
||||
// Run and connect flags cannot be tested because they require an
|
||||
// agent.
|
||||
"--concurrency", "2",
|
||||
"--timeout", "30s",
|
||||
"--job-timeout", "15s",
|
||||
"--cleanup-concurrency", "1",
|
||||
"--cleanup-timeout", "30s",
|
||||
"--cleanup-job-timeout", "15s",
|
||||
"--output", "text",
|
||||
"--output", "json:"+outputFile,
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
done := make(chan any)
|
||||
go func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
cancelFunc()
|
||||
<-done
|
||||
|
||||
// Recreate the context.
|
||||
ctx, cancelFunc = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
// Verify the output file.
|
||||
f, err = os.Open(outputFile)
|
||||
require.NoError(t, err)
|
||||
defer f.Close()
|
||||
var res harness.Results
|
||||
err = json.NewDecoder(f).Decode(&res)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.EqualValues(t, 2, res.TotalRuns)
|
||||
require.EqualValues(t, 2, res.TotalPass)
|
||||
|
||||
// Find the workspaces and users and check that they are what we expect.
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces.Workspaces, 2)
|
||||
|
||||
seenUsers := map[string]struct{}{}
|
||||
for _, w := range workspaces.Workspaces {
|
||||
// Sadly we can't verify params as the API doesn't seem to return
|
||||
// them.
|
||||
|
||||
// Verify that the user is a unique scaletest user.
|
||||
u, err := client.User(ctx, w.OwnerID.String())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, ok := seenUsers[u.ID.String()]
|
||||
require.False(t, ok, "user has more than one workspace")
|
||||
seenUsers[u.ID.String()] = struct{}{}
|
||||
|
||||
require.Contains(t, u.Username, "scaletest-")
|
||||
require.Contains(t, u.Email, "scaletest")
|
||||
}
|
||||
|
||||
require.Len(t, seenUsers, len(workspaces.Workspaces))
|
||||
|
||||
// Check that there are exactly 3 users.
|
||||
users, err := client.Users(ctx, codersdk.UsersRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users.Users, len(seenUsers)+1)
|
||||
|
||||
// Cleanup.
|
||||
cmd, root = clitest.New(t, "scaletest", "cleanup",
|
||||
"--cleanup-concurrency", "1",
|
||||
"--cleanup-timeout", "30s",
|
||||
"--cleanup-job-timeout", "15s",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty = ptytest.New(t)
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
|
||||
done = make(chan any)
|
||||
go func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err)
|
||||
close(done)
|
||||
}()
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
pty.ExpectMatch("Test results:")
|
||||
pty.ExpectMatch("Pass: 2")
|
||||
select {
|
||||
case <-done:
|
||||
case <-ctx.Done():
|
||||
}
|
||||
cancelFunc()
|
||||
<-done
|
||||
|
||||
// Recreate the context (again).
|
||||
ctx, cancelFunc = context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancelFunc()
|
||||
|
||||
// Verify that the workspaces are gone.
|
||||
workspaces, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, workspaces.Workspaces, 0)
|
||||
|
||||
// Verify that the users are gone.
|
||||
users, err = client.Users(ctx, codersdk.UsersRequest{
|
||||
Pagination: codersdk.Pagination{
|
||||
Offset: 0,
|
||||
Limit: 100,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, users.Users, 1)
|
||||
})
|
||||
}
|
||||
@@ -58,6 +58,9 @@ func schedules() *cobra.Command {
|
||||
Annotations: workspaceCommand,
|
||||
Use: "schedule { show | start | stop | override } <workspace>",
|
||||
Short: "Schedule automated start and stop times for workspaces",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
|
||||
scheduleCmd.AddCommand(
|
||||
|
||||
+10
-2
@@ -51,7 +51,11 @@ func TestScheduleShow(t *testing.T) {
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at 7:30AM Mon-Fri (Europe/Dublin)")
|
||||
assert.Contains(t, lines[1], "Starts next 7:30AM IST on ")
|
||||
assert.Contains(t, lines[1], "Starts next 7:30AM")
|
||||
// it should have either IST or GMT
|
||||
if !strings.Contains(lines[1], "IST") && !strings.Contains(lines[1], "GMT") {
|
||||
t.Error("expected either IST or GMT")
|
||||
}
|
||||
assert.Contains(t, lines[2], "Stops at 8h after start")
|
||||
assert.NotContains(t, lines[3], "Stops next -")
|
||||
}
|
||||
@@ -137,7 +141,11 @@ func TestScheduleStart(t *testing.T) {
|
||||
lines := strings.Split(strings.TrimSpace(stdoutBuf.String()), "\n")
|
||||
if assert.Len(t, lines, 4) {
|
||||
assert.Contains(t, lines[0], "Starts at 9:30AM Mon-Fri (Europe/Dublin)")
|
||||
assert.Contains(t, lines[1], "Starts next 9:30AM IST on")
|
||||
assert.Contains(t, lines[1], "Starts next 9:30AM")
|
||||
// it should have either IST or GMT
|
||||
if !strings.Contains(lines[1], "IST") && !strings.Contains(lines[1], "GMT") {
|
||||
t.Error("expected either IST or GMT")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure autostart schedule updated
|
||||
|
||||
+637
-445
File diff suppressed because it is too large
Load Diff
+687
-36
@@ -21,6 +21,7 @@ import (
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -54,10 +55,14 @@ func TestServer(t *testing.T) {
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--postgres-url", connectionURL,
|
||||
"--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)
|
||||
@@ -66,10 +71,9 @@ func TestServer(t *testing.T) {
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
Password: "password",
|
||||
OrganizationName: "example",
|
||||
Email: "some@one.com",
|
||||
Username: "example",
|
||||
Password: "password",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
cancelFunc()
|
||||
@@ -85,7 +89,8 @@ func TestServer(t *testing.T) {
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
@@ -113,11 +118,23 @@ func TestServer(t *testing.T) {
|
||||
|
||||
pty.ExpectMatch("psql")
|
||||
})
|
||||
t.Run("BuiltinPostgresURLRaw", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
root, _ := clitest.New(t, "server", "postgres-builtin-url", "--raw-url")
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
err := root.Execute()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Validate that an http scheme is prepended to a loopback
|
||||
// access URL and that a warning is printed that it may not be externally
|
||||
got := pty.ReadLine()
|
||||
if !strings.HasPrefix(got, "postgres://") {
|
||||
t.Fatalf("expected postgres URL to start with \"postgres://\", got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
// Validate that a warning is printed that it may not be externally
|
||||
// reachable.
|
||||
t.Run("NoSchemeLocalAccessURL", func(t *testing.T) {
|
||||
t.Run("LocalAccessURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
@@ -125,8 +142,8 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "localhost:3000/",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://localhost:3000/",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
@@ -149,7 +166,7 @@ func TestServer(t *testing.T) {
|
||||
|
||||
// Validate that an https scheme is prepended to a remote access URL
|
||||
// and that a warning is printed for a host that cannot be resolved.
|
||||
t.Run("NoSchemeRemoteAccessURL", func(t *testing.T) {
|
||||
t.Run("RemoteAccessURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
@@ -157,8 +174,8 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "foobarbaz.mydomain",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "https://foobarbaz.mydomain",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
pty := ptytest.New(t)
|
||||
@@ -187,7 +204,7 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "https://google.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -208,6 +225,22 @@ func TestServer(t *testing.T) {
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("NoSchemeAccessURL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "google.com",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("TLSBadVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
@@ -216,8 +249,10 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-min-version", "tls9",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -232,28 +267,75 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--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",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
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",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://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()
|
||||
@@ -264,8 +346,10 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--cache-dir", t.TempDir(),
|
||||
@@ -293,6 +377,472 @@ func TestServer(t *testing.T) {
|
||||
cancelFunc()
|
||||
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",
|
||||
"--http-address", "",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--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
|
||||
},
|
||||
},
|
||||
}
|
||||
defer client.HTTPClient.CloseIdleConnections()
|
||||
|
||||
// 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)
|
||||
})
|
||||
|
||||
t.Run("TLSAndHTTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "https://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-redirect-http-to-https=false",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--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)
|
||||
}()
|
||||
|
||||
// We can't use waitAccessURL as it will only return the HTTP URL.
|
||||
const httpLinePrefix = "Started HTTP listener at "
|
||||
pty.ExpectMatch(httpLinePrefix)
|
||||
httpLine := pty.ReadLine()
|
||||
httpAddr := strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
|
||||
require.NotEmpty(t, httpAddr)
|
||||
const tlsLinePrefix = "Started TLS/HTTPS listener at "
|
||||
pty.ExpectMatch(tlsLinePrefix)
|
||||
tlsLine := pty.ReadLine()
|
||||
tlsAddr := strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
|
||||
require.NotEmpty(t, tlsAddr)
|
||||
|
||||
// Verify HTTP
|
||||
httpURL, err := url.Parse(httpAddr)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(httpURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify TLS
|
||||
tlsURL, err := url.Parse(tlsAddr)
|
||||
require.NoError(t, err)
|
||||
client = codersdk.New(tlsURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("TLSRedirect", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
httpListener bool
|
||||
tlsListener bool
|
||||
accessURL string
|
||||
// Empty string means no redirect.
|
||||
expectRedirect string
|
||||
}{
|
||||
{
|
||||
name: "OK",
|
||||
httpListener: true,
|
||||
tlsListener: true,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "https://example.com",
|
||||
},
|
||||
{
|
||||
name: "NoTLSListener",
|
||||
httpListener: true,
|
||||
tlsListener: false,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "",
|
||||
},
|
||||
{
|
||||
name: "NoHTTPListener",
|
||||
httpListener: false,
|
||||
tlsListener: true,
|
||||
accessURL: "https://example.com",
|
||||
expectRedirect: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
c := c
|
||||
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
httpListenAddr := ""
|
||||
if c.httpListener {
|
||||
httpListenAddr = ":0"
|
||||
}
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
flags := []string{
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--cache-dir", t.TempDir(),
|
||||
"--http-address", httpListenAddr,
|
||||
}
|
||||
if c.tlsListener {
|
||||
flags = append(flags,
|
||||
"--tls-enable",
|
||||
"--tls-address", ":0",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
)
|
||||
}
|
||||
if c.accessURL != "" {
|
||||
flags = append(flags, "--access-url", c.accessURL)
|
||||
}
|
||||
|
||||
root, _ := clitest.New(t, flags...)
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
var (
|
||||
httpAddr string
|
||||
tlsAddr string
|
||||
)
|
||||
// We can't use waitAccessURL as it will only return the HTTP URL.
|
||||
if c.httpListener {
|
||||
const httpLinePrefix = "Started HTTP listener at "
|
||||
pty.ExpectMatch(httpLinePrefix)
|
||||
httpLine := pty.ReadLine()
|
||||
httpAddr = strings.TrimSpace(strings.TrimPrefix(httpLine, httpLinePrefix))
|
||||
require.NotEmpty(t, httpAddr)
|
||||
}
|
||||
if c.tlsListener {
|
||||
const tlsLinePrefix = "Started TLS/HTTPS listener at "
|
||||
pty.ExpectMatch(tlsLinePrefix)
|
||||
tlsLine := pty.ReadLine()
|
||||
tlsAddr = strings.TrimSpace(strings.TrimPrefix(tlsLine, tlsLinePrefix))
|
||||
require.NotEmpty(t, tlsAddr)
|
||||
}
|
||||
|
||||
// Verify HTTP redirects (or not)
|
||||
if c.httpListener {
|
||||
httpURL, err := url.Parse(httpAddr)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(httpURL)
|
||||
client.HTTPClient.CheckRedirect = func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
}
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
if c.expectRedirect == "" {
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
} else {
|
||||
require.Equal(t, http.StatusTemporaryRedirect, resp.StatusCode)
|
||||
require.Equal(t, c.expectRedirect, resp.Header.Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
// Verify TLS
|
||||
if c.tlsListener {
|
||||
tlsURL, err := url.Parse(tlsAddr)
|
||||
require.NoError(t, err)
|
||||
client := codersdk.New(tlsURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
return http.ErrUseLastResponse
|
||||
},
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err = client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CanListenUnspecifiedv4", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", "0.0.0.0:0",
|
||||
"--access-url", "http://example.com",
|
||||
)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Started HTTP listener at http://0.0.0.0:")
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("CanListenUnspecifiedv6", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", "[::]:0",
|
||||
"--access-url", "http://example.com",
|
||||
)
|
||||
|
||||
pty := ptytest.New(t)
|
||||
root.SetOutput(pty.Output())
|
||||
root.SetErr(pty.Output())
|
||||
errC := make(chan error, 1)
|
||||
go func() {
|
||||
errC <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("Started HTTP listener at http://[::]:")
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("NoAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", "",
|
||||
"--tls-enable=false",
|
||||
"--tls-address", "",
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "either HTTP or TLS must be enabled")
|
||||
})
|
||||
|
||||
t.Run("NoTLSAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--tls-enable=true",
|
||||
"--tls-address", "",
|
||||
)
|
||||
err := root.ExecuteContext(ctx)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "TLS address must be set if TLS is enabled")
|
||||
})
|
||||
|
||||
// DeprecatedAddress is a test for the deprecated --address flag. If
|
||||
// specified, --http-address and --tls-address are both ignored, a warning
|
||||
// is printed, and the server will either be HTTP-only or TLS-only depending
|
||||
// on if --tls-enable is set.
|
||||
t.Run("DeprecatedAddress", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("HTTP", 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", "http://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)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("--address and -a are deprecated")
|
||||
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
require.Equal(t, "http", accessURL.Scheme)
|
||||
client := codersdk.New(accessURL)
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
|
||||
t.Run("TLS", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
certPath, keyPath := generateTLSCertificate(t)
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--tls-enable",
|
||||
"--tls-cert-file", certPath,
|
||||
"--tls-key-file", keyPath,
|
||||
"--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)
|
||||
}()
|
||||
|
||||
pty.ExpectMatch("--address and -a are deprecated")
|
||||
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
require.Equal(t, "https", accessURL.Scheme)
|
||||
client := codersdk.New(accessURL)
|
||||
client.HTTPClient = &http.Client{
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
//nolint:gosec
|
||||
InsecureSkipVerify: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := client.HasFirstUser(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
cancelFunc()
|
||||
require.NoError(t, <-errC)
|
||||
})
|
||||
})
|
||||
|
||||
// This cannot be ran in parallel because it uses a signal.
|
||||
//nolint:paralleltest
|
||||
t.Run("Shutdown", func(t *testing.T) {
|
||||
@@ -306,7 +856,8 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--provisioner-daemons", "1",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -332,7 +883,8 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--trace=true",
|
||||
"--cache-dir", t.TempDir(),
|
||||
)
|
||||
@@ -369,7 +921,8 @@ func TestServer(t *testing.T) {
|
||||
root, _ := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--telemetry",
|
||||
"--telemetry-url", server.URL,
|
||||
"--cache-dir", t.TempDir(),
|
||||
@@ -399,7 +952,8 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--provisioner-daemons", "1",
|
||||
"--prometheus-enable",
|
||||
"--prometheus-address", ":"+strconv.Itoa(randomPort),
|
||||
@@ -451,7 +1005,9 @@ func TestServer(t *testing.T) {
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--address", ":0",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--oauth2-github-allow-everyone",
|
||||
"--oauth2-github-client-id", "fake",
|
||||
"--oauth2-github-client-secret", "fake",
|
||||
"--oauth2-github-enterprise-base-url", fakeRedirect,
|
||||
@@ -478,18 +1034,112 @@ func TestServer(t *testing.T) {
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
|
||||
t.Run("RateLimit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Default", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "512", resp.Header.Get("X-Ratelimit-Limit"))
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
|
||||
t.Run("Changed", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
val := "100"
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--api-rate-limit", val,
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, val, resp.Header.Get("X-Ratelimit-Limit"))
|
||||
cancelFunc()
|
||||
<-serverErr
|
||||
})
|
||||
|
||||
t.Run("Disabled", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancelFunc := context.WithCancel(context.Background())
|
||||
defer cancelFunc()
|
||||
|
||||
root, cfg := clitest.New(t,
|
||||
"server",
|
||||
"--in-memory",
|
||||
"--http-address", ":0",
|
||||
"--access-url", "http://example.com",
|
||||
"--api-rate-limit", "-1",
|
||||
)
|
||||
serverErr := make(chan error, 1)
|
||||
go func() {
|
||||
serverErr <- root.ExecuteContext(ctx)
|
||||
}()
|
||||
accessURL := waitAccessURL(t, cfg)
|
||||
client := codersdk.New(accessURL)
|
||||
|
||||
resp, err := client.Request(ctx, http.MethodGet, "/api/v2/buildinfo", nil)
|
||||
require.NoError(t, err)
|
||||
defer resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "", resp.Header.Get("X-Ratelimit-Limit"))
|
||||
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),
|
||||
|
||||
@@ -498,6 +1148,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, "")
|
||||
|
||||
+1
-5
@@ -26,11 +26,7 @@ func show() *cobra.Command {
|
||||
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,
|
||||
})
|
||||
|
||||
+3
-3
@@ -18,9 +18,9 @@ func TestShow(t *testing.T) {
|
||||
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,
|
||||
Provision: provisionCompleteWithAgent,
|
||||
ProvisionDryRun: provisionCompleteWithAgent,
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: provisionCompleteWithAgent,
|
||||
ProvisionPlan: provisionCompleteWithAgent,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import (
|
||||
"syscall"
|
||||
)
|
||||
|
||||
var interruptSignals = []os.Signal{
|
||||
var InterruptSignals = []os.Signal{
|
||||
os.Interrupt,
|
||||
syscall.SIGTERM,
|
||||
syscall.SIGHUP,
|
||||
|
||||
@@ -6,4 +6,4 @@ import (
|
||||
"os"
|
||||
)
|
||||
|
||||
var interruptSignals = []os.Signal{os.Interrupt}
|
||||
var InterruptSignals = []os.Signal{os.Interrupt}
|
||||
|
||||
+36
-29
@@ -55,56 +55,63 @@ func speedtest() *cobra.Command {
|
||||
if cliflag.IsSetBool(cmd, varVerbose) {
|
||||
logger = logger.Leveled(slog.LevelDebug)
|
||||
}
|
||||
conn, err := client.DialWorkspaceAgentTailnet(ctx, logger, workspaceAgent.ID)
|
||||
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{
|
||||
Logger: logger,
|
||||
})
|
||||
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:
|
||||
if direct {
|
||||
ticker := time.NewTicker(time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-ticker.C:
|
||||
}
|
||||
dur, p2p, err := conn.Ping(ctx)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
status := conn.Status()
|
||||
if len(status.Peers()) != 1 {
|
||||
continue
|
||||
}
|
||||
peer := status.Peer[status.Peers()[0]]
|
||||
if !p2p && 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
|
||||
}
|
||||
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
|
||||
} else {
|
||||
conn.AwaitReachable(ctx)
|
||||
}
|
||||
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)
|
||||
results, err := conn.Speedtest(ctx, dir, duration)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tableWriter := cliui.Table()
|
||||
tableWriter.AppendHeader(table.Row{"Interval", "Transfer", "Bandwidth"})
|
||||
startTime := results[0].IntervalStart
|
||||
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("%.2f-%.2f sec", r.IntervalStart.Sub(startTime).Seconds(), r.IntervalEnd.Sub(startTime).Seconds()),
|
||||
fmt.Sprintf("%.4f MBits", r.MegaBits()),
|
||||
fmt.Sprintf("%.4f Mbits/sec", r.MBitsPerSecond()),
|
||||
})
|
||||
|
||||
@@ -20,16 +20,15 @@ func TestSpeedtest(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("This test takes a minimum of 5ms per a hardcoded value in Tailscale!")
|
||||
}
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
|
||||
coderdtest.AwaitWorkspaceAgents(t, client, workspace.ID)
|
||||
|
||||
cmd, root := clitest.New(t, "speedtest", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
+239
-18
@@ -1,11 +1,15 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,8 +24,7 @@ import (
|
||||
"golang.org/x/term"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/agent"
|
||||
"github.com/coder/coder/cli/cliflag"
|
||||
"github.com/coder/coder/cli/cliui"
|
||||
"github.com/coder/coder/coderd/autobuild/notify"
|
||||
@@ -40,6 +43,7 @@ func ssh() *cobra.Command {
|
||||
stdio bool
|
||||
shuffle bool
|
||||
forwardAgent bool
|
||||
forwardGPG bool
|
||||
identityAgent string
|
||||
wsPollInterval time.Duration
|
||||
)
|
||||
@@ -74,6 +78,11 @@ func ssh() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
updateWorkspaceBanner, outdated := verifyWorkspaceOutdated(client, workspace)
|
||||
if outdated && isTTYErr(cmd) {
|
||||
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), updateWorkspaceBanner)
|
||||
}
|
||||
|
||||
// OpenSSH passes stderr directly to the calling TTY.
|
||||
// This is required in "stdio" mode so a connecting indicator can be displayed.
|
||||
err = cliui.Agent(ctx, cmd.ErrOrStderr(), cliui.AgentOptions{
|
||||
@@ -86,17 +95,17 @@ func ssh() *cobra.Command {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
}
|
||||
|
||||
conn, err := client.DialWorkspaceAgentTailnet(ctx, slog.Logger{}, workspaceAgent.ID)
|
||||
conn, err := client.DialWorkspaceAgent(ctx, workspaceAgent.ID, &codersdk.DialWorkspaceAgentOptions{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
conn.AwaitReachable(ctx)
|
||||
stopPolling := tryPollWorkspaceAutostop(ctx, client, workspace)
|
||||
defer stopPolling()
|
||||
|
||||
if stdio {
|
||||
rawSSH, err := conn.SSH()
|
||||
rawSSH, err := conn.SSH(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -109,7 +118,7 @@ func ssh() *cobra.Command {
|
||||
return nil
|
||||
}
|
||||
|
||||
sshClient, err := conn.SSHClient()
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -134,7 +143,7 @@ func ssh() *cobra.Command {
|
||||
if forwardAgent && identityAgent != "" {
|
||||
err = gosshagent.ForwardToRemote(sshClient, identityAgent)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward agent failed: %w", err)
|
||||
return xerrors.Errorf("forward agent: %w", err)
|
||||
}
|
||||
err = gosshagent.RequestAgentForwarding(sshSession)
|
||||
if err != nil {
|
||||
@@ -142,6 +151,22 @@ func ssh() *cobra.Command {
|
||||
}
|
||||
}
|
||||
|
||||
if forwardGPG {
|
||||
if workspaceAgent.OperatingSystem == "windows" {
|
||||
return xerrors.New("GPG forwarding is not supported for Windows workspaces")
|
||||
}
|
||||
|
||||
err = uploadGPGKeys(ctx, sshClient)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("upload GPG public keys and ownertrust to workspace: %w", err)
|
||||
}
|
||||
closer, err := forwardGPGAgent(ctx, cmd.ErrOrStderr(), sshClient)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("forward GPG socket: %w", err)
|
||||
}
|
||||
defer closer.Close()
|
||||
}
|
||||
|
||||
stdoutFile, validOut := cmd.OutOrStdout().(*os.File)
|
||||
stdinFile, validIn := cmd.InOrStdin().(*os.File)
|
||||
if validOut && validIn && isatty.IsTerminal(stdoutFile.Fd()) {
|
||||
@@ -195,10 +220,12 @@ func ssh() *cobra.Command {
|
||||
_ = 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 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")
|
||||
}
|
||||
@@ -212,6 +239,7 @@ func ssh() *cobra.Command {
|
||||
cliflag.BoolVarP(cmd.Flags(), &shuffle, "shuffle", "", "CODER_SSH_SHUFFLE", false, "Specifies whether to choose a random workspace")
|
||||
_ = cmd.Flags().MarkHidden("shuffle")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardAgent, "forward-agent", "A", "CODER_SSH_FORWARD_AGENT", false, "Specifies whether to forward the SSH agent specified in $SSH_AUTH_SOCK")
|
||||
cliflag.BoolVarP(cmd.Flags(), &forwardGPG, "forward-gpg", "G", "CODER_SSH_FORWARD_GPG", false, "Specifies whether to forward the GPG agent. Unsupported on Windows workspaces, but supports all clients. Requires gnupg (gpg, gpgconf) on both the client and workspace. The GPG agent must already be running locally and will not be started for you. If a GPG agent is already running in the workspace, it will be attempted to be killed.")
|
||||
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
|
||||
@@ -227,17 +255,17 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
|
||||
err error
|
||||
)
|
||||
if shuffle {
|
||||
workspaces, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: codersdk.Me,
|
||||
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Owner: userID,
|
||||
})
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
if len(workspaces) == 0 {
|
||||
if len(res.Workspaces) == 0 {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("no workspaces to shuffle")
|
||||
}
|
||||
|
||||
workspace, err = cryptorand.Element(workspaces)
|
||||
workspace, err = cryptorand.Element(res.Workspaces)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
@@ -252,7 +280,7 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, xerrors.New("workspace must be in start transition to ssh")
|
||||
}
|
||||
if workspace.LatestBuild.Job.CompletedAt == nil {
|
||||
err := cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID, workspace.CreatedAt)
|
||||
err := cliui.WorkspaceBuild(ctx, cmd.ErrOrStderr(), client, workspace.LatestBuild.ID)
|
||||
if err != nil {
|
||||
return codersdk.Workspace{}, codersdk.WorkspaceAgent{}, err
|
||||
}
|
||||
@@ -261,10 +289,7 @@ func getWorkspaceAndAgent(ctx context.Context, cmd *cobra.Command, client *coder
|
||||
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 {
|
||||
@@ -348,3 +373,199 @@ func notifyCondition(ctx context.Context, client *codersdk.Client, workspaceID u
|
||||
return deadline.Truncate(time.Minute), callback
|
||||
}
|
||||
}
|
||||
|
||||
// Verify if the user workspace is outdated and prepare an actionable message for user.
|
||||
func verifyWorkspaceOutdated(client *codersdk.Client, workspace codersdk.Workspace) (string, bool) {
|
||||
if !workspace.Outdated {
|
||||
return "", false // workspace is up-to-date
|
||||
}
|
||||
|
||||
workspaceLink := buildWorkspaceLink(client.URL, workspace)
|
||||
return fmt.Sprintf("👋 Your workspace is outdated! Update it here: %s\n", workspaceLink), true
|
||||
}
|
||||
|
||||
// Build the user workspace link which navigates to the Coder web UI.
|
||||
func buildWorkspaceLink(serverURL *url.URL, workspace codersdk.Workspace) *url.URL {
|
||||
return serverURL.ResolveReference(&url.URL{Path: fmt.Sprintf("@%s/%s", workspace.OwnerName, workspace.Name)})
|
||||
}
|
||||
|
||||
// runLocal runs a command on the local machine.
|
||||
func runLocal(ctx context.Context, stdin io.Reader, name string, args ...string) ([]byte, error) {
|
||||
cmd := exec.CommandContext(ctx, name, args...)
|
||||
cmd.Stdin = stdin
|
||||
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
var stderr []byte
|
||||
if exitErr := new(exec.ExitError); errors.As(err, &exitErr) {
|
||||
stderr = exitErr.Stderr
|
||||
}
|
||||
|
||||
return out, xerrors.Errorf(
|
||||
"`%s %s` failed: stderr: %s\n\nstdout: %s\n\n%w",
|
||||
name,
|
||||
strings.Join(args, " "),
|
||||
bytes.TrimSpace(stderr),
|
||||
bytes.TrimSpace(out),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// runRemoteSSH runs a command on a remote machine/workspace via SSH.
|
||||
func runRemoteSSH(sshClient *gossh.Client, stdin io.Reader, cmd string) ([]byte, error) {
|
||||
sess, err := sshClient.NewSession()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("create SSH session")
|
||||
}
|
||||
defer sess.Close()
|
||||
|
||||
stderr := bytes.NewBuffer(nil)
|
||||
sess.Stdin = stdin
|
||||
sess.Stderr = stderr
|
||||
|
||||
out, err := sess.Output(cmd)
|
||||
if err != nil {
|
||||
return out, xerrors.Errorf(
|
||||
"`%s` failed: stderr: %s\n\nstdout: %s:\n\n%w",
|
||||
cmd,
|
||||
bytes.TrimSpace(stderr.Bytes()),
|
||||
bytes.TrimSpace(out),
|
||||
err,
|
||||
)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func uploadGPGKeys(ctx context.Context, sshClient *gossh.Client) error {
|
||||
// Check if the agent is running in the workspace already.
|
||||
//
|
||||
// Note: we don't support windows in the workspace for GPG forwarding so
|
||||
// using shell commands is fine.
|
||||
//
|
||||
// Note: we sleep after killing the agent because it doesn't always die
|
||||
// immediately.
|
||||
agentSocketBytes, err := runRemoteSSH(sshClient, nil, `
|
||||
set -eux
|
||||
agent_socket=$(gpgconf --list-dir agent-socket)
|
||||
echo "$agent_socket"
|
||||
if [ -S "$agent_socket" ]; then
|
||||
echo "agent socket exists, attempting to kill it" >&2
|
||||
gpgconf --kill gpg-agent
|
||||
rm -f "$agent_socket"
|
||||
sleep 1
|
||||
fi
|
||||
|
||||
test ! -S "$agent_socket"
|
||||
`)
|
||||
agentSocket := strings.TrimSpace(string(agentSocketBytes))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("check if agent socket is running (check if %q exists): %w", agentSocket, err)
|
||||
}
|
||||
if agentSocket == "" {
|
||||
return xerrors.Errorf("agent socket path is empty, check the output of `gpgconf --list-dir agent-socket`")
|
||||
}
|
||||
|
||||
// Read the user's public keys and ownertrust from GPG.
|
||||
pubKeyExport, err := runLocal(ctx, nil, "gpg", "--armor", "--export")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("export local public keys from GPG: %w", err)
|
||||
}
|
||||
ownerTrustExport, err := runLocal(ctx, nil, "gpg", "--export-ownertrust")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("export local ownertrust from GPG: %w", err)
|
||||
}
|
||||
|
||||
// Import the public keys and ownertrust into the workspace.
|
||||
_, err = runRemoteSSH(sshClient, bytes.NewReader(pubKeyExport), "gpg --import")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("import public keys into workspace: %w", err)
|
||||
}
|
||||
_, err = runRemoteSSH(sshClient, bytes.NewReader(ownerTrustExport), "gpg --import-ownertrust")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("import ownertrust into workspace: %w", err)
|
||||
}
|
||||
|
||||
// Kill the agent in the workspace if it was started by one of the above
|
||||
// commands.
|
||||
_, err = runRemoteSSH(sshClient, nil, fmt.Sprintf("gpgconf --kill gpg-agent && rm -f %q", agentSocket))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("kill existing agent in workspace: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func localGPGExtraSocket(ctx context.Context) (string, error) {
|
||||
localSocket, err := runLocal(ctx, nil, "gpgconf", "--list-dir", "agent-extra-socket")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get local GPG agent socket: %w", err)
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(localSocket)), nil
|
||||
}
|
||||
|
||||
func remoteGPGAgentSocket(sshClient *gossh.Client) (string, error) {
|
||||
remoteSocket, err := runRemoteSSH(sshClient, nil, "gpgconf --list-dir agent-socket")
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get remote GPG agent socket: %w", err)
|
||||
}
|
||||
|
||||
return string(bytes.TrimSpace(remoteSocket)), nil
|
||||
}
|
||||
|
||||
// cookieAddr is a special net.Addr accepted by sshForward() which includes a
|
||||
// cookie which is written to the connection before forwarding.
|
||||
type cookieAddr struct {
|
||||
net.Addr
|
||||
cookie []byte
|
||||
}
|
||||
|
||||
// sshForwardRemote starts forwarding connections from a remote listener to a
|
||||
// local address via SSH in a goroutine.
|
||||
//
|
||||
// Accepts a `cookieAddr` as the local address.
|
||||
func sshForwardRemote(ctx context.Context, stderr io.Writer, sshClient *gossh.Client, localAddr, remoteAddr net.Addr) (io.Closer, error) {
|
||||
listener, err := sshClient.Listen(remoteAddr.Network(), remoteAddr.String())
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("listen on remote SSH address %s: %w", remoteAddr.String(), err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
for {
|
||||
remoteConn, err := listener.Accept()
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Accept SSH listener connection: %+v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer remoteConn.Close()
|
||||
|
||||
localConn, err := net.Dial(localAddr.Network(), localAddr.String())
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Dial local address %s: %+v\n", localAddr.String(), err)
|
||||
return
|
||||
}
|
||||
defer localConn.Close()
|
||||
|
||||
if c, ok := localAddr.(cookieAddr); ok {
|
||||
_, err = localConn.Write(c.cookie)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(stderr, "Write cookie to local connection: %+v\n", err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
agent.Bicopy(ctx, localConn, remoteConn)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
return listener, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/codersdk"
|
||||
)
|
||||
|
||||
const (
|
||||
fakeOwnerName = "fake-owner-name"
|
||||
fakeServerURL = "https://fake-foo-url"
|
||||
fakeWorkspaceName = "fake-workspace-name"
|
||||
)
|
||||
|
||||
func TestVerifyWorkspaceOutdated(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL, err := url.Parse(fakeServerURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
client := codersdk.Client{URL: serverURL}
|
||||
|
||||
t.Run("Up-to-date", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName}
|
||||
|
||||
_, outdated := verifyWorkspaceOutdated(&client, workspace)
|
||||
|
||||
assert.False(t, outdated, "workspace should be up-to-date")
|
||||
})
|
||||
t.Run("Outdated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName, Outdated: true}
|
||||
|
||||
updateWorkspaceBanner, outdated := verifyWorkspaceOutdated(&client, workspace)
|
||||
|
||||
assert.True(t, outdated, "workspace should be outdated")
|
||||
assert.NotEmpty(t, updateWorkspaceBanner, "workspace banner should be present")
|
||||
})
|
||||
}
|
||||
|
||||
func TestBuildWorkspaceLink(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
serverURL, err := url.Parse(fakeServerURL)
|
||||
require.NoError(t, err)
|
||||
|
||||
workspace := codersdk.Workspace{Name: fakeWorkspaceName, OwnerName: fakeOwnerName}
|
||||
workspaceLink := buildWorkspaceLink(serverURL, workspace)
|
||||
|
||||
assert.Equal(t, workspaceLink.String(), fakeServerURL+"/@"+fakeOwnerName+"/"+fakeWorkspaceName)
|
||||
}
|
||||
@@ -5,9 +5,12 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/signal"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
@@ -20,3 +23,26 @@ func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
}()
|
||||
return windowSize
|
||||
}
|
||||
|
||||
func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Client) (io.Closer, error) {
|
||||
localSocket, err := localGPGExtraSocket(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
remoteSocket, err := remoteGPGAgentSocket(sshClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localAddr := &net.UnixAddr{
|
||||
Name: localSocket,
|
||||
Net: "unix",
|
||||
}
|
||||
remoteAddr := &net.UnixAddr{
|
||||
Name: remoteSocket,
|
||||
Net: "unix",
|
||||
}
|
||||
|
||||
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
|
||||
}
|
||||
|
||||
+302
-22
@@ -1,15 +1,20 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -27,30 +32,36 @@ import (
|
||||
"github.com/coder/coder/codersdk"
|
||||
"github.com/coder/coder/provisioner/echo"
|
||||
"github.com/coder/coder/provisionersdk/proto"
|
||||
"github.com/coder/coder/pty"
|
||||
"github.com/coder/coder/pty/ptytest"
|
||||
"github.com/coder/coder/testutil"
|
||||
)
|
||||
|
||||
func setupWorkspaceForAgent(t *testing.T) (*codersdk.Client, codersdk.Workspace, string) {
|
||||
func setupWorkspaceForAgent(t *testing.T, mutate func([]*proto.Agent) []*proto.Agent) (*codersdk.Client, codersdk.Workspace, string) {
|
||||
t.Helper()
|
||||
if mutate == nil {
|
||||
mutate = func(a []*proto.Agent) []*proto.Agent {
|
||||
return a
|
||||
}
|
||||
}
|
||||
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{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionDryRun: echo.ProvisionComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionPlan: echo.ProvisionComplete,
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
Resources: []*proto.Resource{{
|
||||
Name: "dev",
|
||||
Type: "google_compute_instance",
|
||||
Agents: []*proto.Agent{{
|
||||
Agents: mutate([]*proto.Agent{{
|
||||
Id: uuid.NewString(),
|
||||
Auth: &proto.Agent_Token{
|
||||
Token: agentToken,
|
||||
},
|
||||
}},
|
||||
}}),
|
||||
}},
|
||||
},
|
||||
},
|
||||
@@ -60,6 +71,8 @@ func setupWorkspaceForAgent(t *testing.T) (*codersdk.Client, codersdk.Workspace,
|
||||
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)
|
||||
workspace, err := client.Workspace(context.Background(), workspace.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
return client, workspace, agentToken
|
||||
}
|
||||
@@ -69,7 +82,7 @@ func TestSSH(t *testing.T) {
|
||||
t.Run("ImmediateExit", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
cmd, root := clitest.New(t, "ssh", workspace.Name)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
@@ -87,11 +100,10 @@ func TestSSH(t *testing.T) {
|
||||
pty.ExpectMatch("Waiting")
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer func() {
|
||||
_ = agentCloser.Close()
|
||||
@@ -101,18 +113,46 @@ func TestSSH(t *testing.T) {
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
t.Run("ShowTroubleshootingURLAfterTimeout", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
wantURL := "https://example.com/troubleshoot"
|
||||
client, workspace, _ := setupWorkspaceForAgent(t, func(a []*proto.Agent) []*proto.Agent {
|
||||
// Unfortunately, one second is the lowest
|
||||
// we can go because 0 disables the feature.
|
||||
a[0].ConnectionTimeoutSeconds = 1
|
||||
a[0].TroubleshootingUrl = wantURL
|
||||
return a
|
||||
})
|
||||
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.ExecuteContext(ctx)
|
||||
assert.ErrorIs(t, err, context.Canceled)
|
||||
})
|
||||
pty.ExpectMatch(wantURL)
|
||||
cancel()
|
||||
<-cmdDone
|
||||
})
|
||||
t.Run("Stdio", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
_, _ = tGoContext(t, func(ctx context.Context) {
|
||||
// Run this async so the SSH command has to wait for
|
||||
// the build and agent to connect!
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
<-ctx.Done()
|
||||
_ = agentCloser.Close()
|
||||
@@ -173,14 +213,13 @@ func TestSSH(t *testing.T) {
|
||||
|
||||
t.Parallel()
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t)
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SessionToken = agentToken
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
FetchMetadata: agentClient.WorkspaceAgentMetadata,
|
||||
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
Client: agentClient,
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
@@ -193,7 +232,7 @@ func TestSSH(t *testing.T) {
|
||||
})
|
||||
|
||||
// Start up ssh agent listening on unix socket.
|
||||
tmpdir := t.TempDir()
|
||||
tmpdir := tempDirUnixSocket(t)
|
||||
agentSock := filepath.Join(tmpdir, "agent.sock")
|
||||
l, err := net.Listen("unix", agentSock)
|
||||
require.NoError(t, err)
|
||||
@@ -250,6 +289,224 @@ func TestSSH(t *testing.T) {
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
//nolint:paralleltest // This test uses t.Setenv.
|
||||
t.Run("ForwardGPG", func(t *testing.T) {
|
||||
if runtime.GOOS == "windows" {
|
||||
// While GPG forwarding from a Windows client works, we currently do
|
||||
// not support forwarding to a Windows workspace. Our tests use the
|
||||
// same platform for the "client" and "workspace" as they run in the
|
||||
// same process.
|
||||
t.Skip("Test not supported on windows")
|
||||
}
|
||||
|
||||
// This key is for dean@coder.com.
|
||||
const randPublicKeyFingerprint = "7BDFBA0CC7F5A96537C806C427BC6335EB5117F1"
|
||||
const randPublicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
|
||||
|
||||
mQINBF6SWkEBEADB8sAhBaT36VQ6HEhAmtKexLldu1HUdXNw16rdF+1wiBzSFfJN
|
||||
aPeX4Y9iFIZgC2wU0wOjJ04BpioyOLtJngbThI5WpeoQ/1yQZOpnDaCMPPLp+uJ+
|
||||
Gy4tMZYWQq21PukrFm3XDRGKjVN58QN6uCPb1S/YzteP8Epmq590GYIYLiAHnMt6
|
||||
5iyxIFhXj/fq5Fddp2+efI7QWvNl2wTNnCaTziOSKYcbNmQpn9gy0WvKktWYtB8E
|
||||
JJtWES0DzgCnDpm/hYx79Wkb+F7qY54y2uauDx+z97QXrON47lsIyGm8/T59ZfSd
|
||||
/yrBqDLHYrHlt9RkFpAnBzO402y2eHsKTB6/EAHv9H2apxahyJlcxGbE5QE+fOJk
|
||||
LdPlako0cSljz0g9Icesr2nZL0MhWwLnwk7DHkg/PUUijkbuR/TD9dti2/yOTFrf
|
||||
Y7DdZpoZ0ZkcGu9lMh2vOTWc96RNCyIZfE5WNDKKo+u5Txzndsc/qIgKohwDSxTC
|
||||
3hAulG5Wt05UeyHBEAAvGV2szG88VsGwd1juqXAbEzk+kLQzNyoQX188/4V4X+MV
|
||||
pY9Wz7JudmQpB/3+YTcA/ziK/+wu3c2wNlr7gMZYMOwDWTLfW64nux7zHWDytrP0
|
||||
HfgJIgqP7F7SnChpTFdb1hr1WDox99ZG+/eDkwxnuXYWm9xx5/crqQ0POQARAQAB
|
||||
tClEZWFuIFNoZWF0aGVyICh3b3JrIGtleSkgPGRlYW5AY29kZXIuY29tPokCVAQT
|
||||
AQgAPhYhBHvfugzH9allN8gGxCe8YzXrURfxBQJeklpBAhsDBQkJZgGABQsJCAcC
|
||||
BhUKCQgLAgQWAgMBAh4BAheAAAoJECe8YzXrURfxIVkP/3UJMzvIjTNF63WiK4xk
|
||||
TXlBbPKodnzUmAJ+8DVXmJMJpNsSI2czw6eFUXMcrT3JMlviOXhRWMLHr2FsQhyS
|
||||
AJOQo0x9z7nntPIkvj96ihCdgRn7VN1WzaMwOOesGPr57StWLE84bg9/R0aSsxtX
|
||||
LgfBCyNkv6FFlruhnw8+JdZJEjvIXQ9swvwD6L68ZLWIWcdnj/CjQmnmgFA+O4UO
|
||||
SFXMUjklbrq8mJ0sAPUUATJK0SOTyqkZPkhqjlTZa8p0XoJF25trhwLhzDi4GPR6
|
||||
SK/9SkqB/go9ZwkNZOjs2tP7eMExy4zQ21MFH09JMKQB7H5CG8GwdMwz4+VKc9aP
|
||||
y9Ncova/p7Y8kJ7oQPWhACJT1jMP6620oC2N/7wwS0Vtc6E9LoPrfXC2TtvOA9qx
|
||||
aOf6riWSjo8BEcXDuMtlW4g6IQFNd0+wcgcKrAd+vPLZnG4rtYL0Etdd1ymBT4pi
|
||||
5E5uT8oUT9rLHX+2tD/E8SE5PzsaKEOJKzcOB8ESb3YBGic7+VvX/AuJuSFsuWnZ
|
||||
FqAUENqfdz6+0dEJe1pfWyje+Q+o7B7u+ffMT4dOQOC8NfHFnz1kU+DA3VDE6xsu
|
||||
3YN1L8KlYON92s9VWDA8VuvmU2d9pq5ysUeg133ftDSwj3X+5GYcBv4VFcSRCBW5
|
||||
w0hDpMDun1t8xcXdo1LQ4R4NuQINBF6SWkEBEADF4Nrhlqc5M3Sz9sNHDJZR68zb
|
||||
4CjkoOpYwsKj/ZCukzRCGKpT5Agn0zOycUjbAyCZVjREeIRRURyAhfpOmZY5yF6b
|
||||
PD93+04OzWk1AaDRmMfvi1Crn/WUEVHIbDaisxDzNuAJgLrt93I/lOz06GczhCb6
|
||||
sPBeKuaXCLl/5LSwTahGWsweeSCmfyrYsOc11T+SjdyWXWXEpzFNNIhvqiEoJCw3
|
||||
IcdktTBJYuHsN4jh5kVemi/ttqRN3z7rBMKR1sPG3ux1MfCfSTSCeZLTN9eVvqm9
|
||||
ne8brk8ZC6sdwlZ9IofPbmSaAh+F5Kfcnd3KjmyQ63t+8plpJ2YH3Fx6IwTwVEQ8
|
||||
Ii3WQInTpBSPqf0EwnzRBvhYeKusRpcmX3JSmosLbd5uhvJdgotzuwZYzgay/6DL
|
||||
OlwElZ//ecXNhU8iYmx1BwNuquvGcGVpkP5eaaT6O9qDznB7TT0xztfAK0LaAuRJ
|
||||
HOFCc8iiHtQ4o0OkRhg/0KkUGBU5Iw5SIDimkgwJMtD3ZiYOqLaXS6kmmVw2u6YD
|
||||
LB8rTpegz/tcX+4uyfnIZ28JCOYFTeaDT4FixFW2hrfo/VJzMI5IIv9XAAmtAiEU
|
||||
f+CY2BT6kg9NkQuke0p4/W8yTaScapYZa5I2bzFpJJyzh1TKE6x3qcbBs9vVX+6E
|
||||
vK4FflNwu9WSWojO2wARAQABiQI8BBgBCAAmFiEEe9+6DMf1qWU3yAbEJ7xjNetR
|
||||
F/EFAl6SWkECGwwFCQlmAYAACgkQJ7xjNetRF/FpnQ//SIYePQzhvWj9drnT2krG
|
||||
dUGSxCN0pA2UQZNkreAaKmyxn2/6xEdxYSz0iUEk+I0HKay+NLCxJ5PDoDBypFtM
|
||||
f0yOnbWRObhim8HmED4JRw678G4hRU7KEN0L/9SUYlsBNbgr1xYM/CUX/Ih9NT+P
|
||||
eApxs2VgjKii6m81nfBCFpWSxAs+TOnbshp8dlDZk9kxjFH9+h1ffgZjntqeyiWe
|
||||
F1UE1Wh32MbJdtc2Y3mrA6i+7+3OXmqMHoiG1obhISgdpaCJ/ub3ywnAmeXSiAKE
|
||||
IuS6CriR71Wqv8LMQ8kPM8On9Q26d1dsKKBnlFop9oexxf1AFsbbf9gkcgb+uNno
|
||||
1Qr/R6l2H1TcV1gmiyQLzVnkgLRORosLvSlFrisrsLv9uTYYgcGvwKiU/o3PTdQg
|
||||
fv0D7LB+a3C9KsCBFjihW3bTOcHKX2sAWEQXZMtKGf5aNTBmWQ+eKWUGpudXIvLE
|
||||
od5lgfk9p8T1R50KDieG/+2X95zxFSYBoPRAfp7JNT7h+TZ55qUmQXZGI1VqhWiq
|
||||
b6y/yqfI17JCm4oWpXYbgeruLuye2c/ptDc3S3d26hbWYiWKVT4bLtUGR0wuE6lS
|
||||
DK0u4LK+mnrYfIvRDYJGx18/nbLpR+ivWLIssJT2Jyyj8w9+hk10XkODySNjHCxj
|
||||
p7KeSZdlk47pMBGOfnvEmoQ=
|
||||
=OxHv
|
||||
-----END PGP PUBLIC KEY BLOCK-----`
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
gpgPath, err := exec.LookPath("gpg")
|
||||
if err != nil {
|
||||
t.Skip("gpg not found")
|
||||
}
|
||||
gpgConfPath, err := exec.LookPath("gpgconf")
|
||||
if err != nil {
|
||||
t.Skip("gpgconf not found")
|
||||
}
|
||||
gpgAgentPath, err := exec.LookPath("gpg-agent")
|
||||
if err != nil {
|
||||
t.Skip("gpg-agent not found")
|
||||
}
|
||||
|
||||
// Setup GPG home directory on the "client".
|
||||
gnupgHomeClient := tempDirUnixSocket(t)
|
||||
t.Setenv("GNUPGHOME", gnupgHomeClient)
|
||||
|
||||
// Get the agent extra socket path.
|
||||
var (
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
)
|
||||
c := exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-extra-socket")
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get extra socket path failed: %s", stderr.String())
|
||||
extraSocketPath := strings.TrimSpace(stdout.String())
|
||||
|
||||
// Generate private key non-interactively.
|
||||
genKeyScript := `
|
||||
Key-Type: 1
|
||||
Key-Length: 2048
|
||||
Subkey-Type: 1
|
||||
Subkey-Length: 2048
|
||||
Name-Real: Coder Test
|
||||
Name-Email: test@coder.com
|
||||
Expire-Date: 0
|
||||
%no-protection
|
||||
`
|
||||
c = exec.CommandContext(ctx, gpgPath, "--batch", "--gen-key")
|
||||
c.Stdin = strings.NewReader(genKeyScript)
|
||||
out, err := c.CombinedOutput()
|
||||
require.NoError(t, err, "generate key failed: %s", out)
|
||||
|
||||
// Import a random public key.
|
||||
stdin := strings.NewReader(randPublicKey + "\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import", "-")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import key failed: %s", out)
|
||||
|
||||
// Set ultimate trust on imported key.
|
||||
stdin = strings.NewReader(randPublicKeyFingerprint + ":6:\n")
|
||||
c = exec.CommandContext(ctx, gpgPath, "--import-ownertrust")
|
||||
c.Stdin = stdin
|
||||
out, err = c.CombinedOutput()
|
||||
require.NoError(t, err, "import ownertrust failed: %s", out)
|
||||
|
||||
// Start the GPG agent.
|
||||
agentCmd := exec.CommandContext(ctx, gpgAgentPath, "--no-detach", "--extra-socket", extraSocketPath)
|
||||
agentCmd.Env = append(agentCmd.Env, "GNUPGHOME="+gnupgHomeClient)
|
||||
agentPTY, agentProc, err := pty.Start(agentCmd, pty.WithPTYOption(pty.WithGPGTTY()))
|
||||
require.NoError(t, err, "launch agent failed")
|
||||
defer func() {
|
||||
_ = agentProc.Kill()
|
||||
_ = agentPTY.Close()
|
||||
}()
|
||||
|
||||
// Get the agent socket path in the "workspace".
|
||||
gnupgHomeWorkspace := tempDirUnixSocket(t)
|
||||
|
||||
stdout = bytes.NewBuffer(nil)
|
||||
stderr = bytes.NewBuffer(nil)
|
||||
c = exec.CommandContext(ctx, gpgConfPath, "--list-dir", "agent-socket")
|
||||
c.Env = append(c.Env, "GNUPGHOME="+gnupgHomeWorkspace)
|
||||
c.Stdout = stdout
|
||||
c.Stderr = stderr
|
||||
err = c.Run()
|
||||
require.NoError(t, err, "get agent socket path in workspace failed: %s", stderr.String())
|
||||
workspaceAgentSocketPath := strings.TrimSpace(stdout.String())
|
||||
require.NotEqual(t, extraSocketPath, workspaceAgentSocketPath, "socket path should be different")
|
||||
|
||||
client, workspace, agentToken := setupWorkspaceForAgent(t, nil)
|
||||
|
||||
agentClient := codersdk.New(client.URL)
|
||||
agentClient.SetSessionToken(agentToken)
|
||||
agentCloser := agent.New(agent.Options{
|
||||
Client: agentClient,
|
||||
EnvironmentVariables: map[string]string{
|
||||
"GNUPGHOME": gnupgHomeWorkspace,
|
||||
},
|
||||
Logger: slogtest.Make(t, nil).Named("agent"),
|
||||
})
|
||||
defer agentCloser.Close()
|
||||
|
||||
cmd, root := clitest.New(t,
|
||||
"ssh",
|
||||
workspace.Name,
|
||||
"--forward-gpg",
|
||||
)
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t)
|
||||
cmd.SetIn(pty.Input())
|
||||
cmd.SetOut(pty.Output())
|
||||
cmd.SetErr(pty.Output())
|
||||
cmdDone := tGo(t, func() {
|
||||
err := cmd.ExecuteContext(ctx)
|
||||
assert.NoError(t, err, "ssh command failed")
|
||||
})
|
||||
// Prevent the test from hanging if the asserts below kill the test
|
||||
// early. This will cause the command to exit with an error, which will
|
||||
// let the t.Cleanup'd `<-done` inside of `tGo` exit and not hang.
|
||||
// Without this, the test will hang forever on failure, preventing the
|
||||
// real error from being printed.
|
||||
t.Cleanup(cancel)
|
||||
|
||||
pty.WriteLine("echo hello 'world'")
|
||||
pty.ExpectMatch("hello world")
|
||||
|
||||
// Check the GNUPGHOME was correctly inherited via shell.
|
||||
pty.WriteLine("env && echo env-''-command-done")
|
||||
match := pty.ExpectMatch("env--command-done")
|
||||
require.Contains(t, match, "GNUPGHOME="+gnupgHomeWorkspace, match)
|
||||
|
||||
// Get the agent extra socket path in the "workspace" via shell.
|
||||
pty.WriteLine("gpgconf --list-dir agent-socket && echo gpgconf-''-agentsocket-command-done")
|
||||
pty.ExpectMatch(workspaceAgentSocketPath)
|
||||
pty.ExpectMatch("gpgconf--agentsocket-command-done")
|
||||
|
||||
// List the keys in the "workspace".
|
||||
pty.WriteLine("gpg --list-keys && echo gpg-''-listkeys-command-done")
|
||||
listKeysOutput := pty.ExpectMatch("gpg--listkeys-command-done")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Coder Test <test@coder.com>")
|
||||
require.Contains(t, listKeysOutput, "[ultimate] Dean Sheather (work key) <dean@coder.com>")
|
||||
|
||||
// Try to sign something. This demonstrates that the forwarding is
|
||||
// working as expected, since the workspace doesn't have access to the
|
||||
// private key directly and must use the forwarded agent.
|
||||
pty.WriteLine("echo 'hello world' | gpg --clearsign && echo gpg-''-sign-command-done")
|
||||
pty.ExpectMatch("BEGIN PGP SIGNED MESSAGE")
|
||||
pty.ExpectMatch("Hash:")
|
||||
pty.ExpectMatch("hello world")
|
||||
pty.ExpectMatch("gpg--sign-command-done")
|
||||
|
||||
// And we're done.
|
||||
pty.WriteLine("exit")
|
||||
<-cmdDone
|
||||
})
|
||||
}
|
||||
|
||||
// tGoContext runs fn in a goroutine passing a context that will be
|
||||
@@ -323,3 +580,26 @@ func (*stdioConn) SetReadDeadline(_ time.Time) error {
|
||||
func (*stdioConn) SetWriteDeadline(_ time.Time) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// tempDirUnixSocket returns a temporary directory that can safely hold unix
|
||||
// sockets (probably).
|
||||
//
|
||||
// During tests on darwin we hit the max path length limit for unix sockets
|
||||
// pretty easily in the default location, so this function uses /tmp instead to
|
||||
// get shorter paths.
|
||||
func tempDirUnixSocket(t *testing.T) string {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "darwin" {
|
||||
testName := strings.ReplaceAll(t.Name(), "/", "_")
|
||||
dir, err := os.MkdirTemp("/tmp", fmt.Sprintf("coder-test-%s-", testName))
|
||||
require.NoError(t, err, "create temp dir for gpg test")
|
||||
|
||||
t.Cleanup(func() {
|
||||
err := os.RemoveAll(dir)
|
||||
assert.NoError(t, err, "remove temp dir", dir)
|
||||
})
|
||||
return dir
|
||||
}
|
||||
|
||||
return t.TempDir()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,16 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
@@ -25,3 +32,74 @@ func listenWindowSize(ctx context.Context) <-chan os.Signal {
|
||||
}()
|
||||
return windowSize
|
||||
}
|
||||
|
||||
func forwardGPGAgent(ctx context.Context, stderr io.Writer, sshClient *gossh.Client) (io.Closer, error) {
|
||||
// Read TCP port and cookie from extra socket file. A gpg-agent socket
|
||||
// file looks like the following:
|
||||
//
|
||||
// 49955
|
||||
// abcdefghijklmnop
|
||||
//
|
||||
// The first line is the TCP port that gpg-agent is listening on, and
|
||||
// the second line is a 16 byte cookie that MUST be sent as the first
|
||||
// bytes of any connection to this port (otherwise the connection is
|
||||
// closed by gpg-agent).
|
||||
localSocket, err := localGPGExtraSocket(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f, err := os.Open(localSocket)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("open gpg-agent-extra socket file %q: %w", localSocket, err)
|
||||
}
|
||||
|
||||
// Scan lines from file to get port and cookie.
|
||||
var (
|
||||
port uint16
|
||||
cookie []byte
|
||||
scanner = bufio.NewScanner(f)
|
||||
)
|
||||
for i := 0; scanner.Scan(); i++ {
|
||||
switch i {
|
||||
case 0:
|
||||
port64, err := strconv.ParseUint(scanner.Text(), 10, 16)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: line 1: convert string to integer: %w", localSocket, err)
|
||||
}
|
||||
port = uint16(port64)
|
||||
|
||||
case 1:
|
||||
cookie = scanner.Bytes()
|
||||
if len(cookie) != 16 {
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: line 2: expected 16 bytes, got %v bytes", localSocket, len(cookie))
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file %q: file contains more than 2 lines", localSocket)
|
||||
}
|
||||
}
|
||||
|
||||
err = scanner.Err()
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse gpg-agent-extra socket file: %q: %w", localSocket, err)
|
||||
}
|
||||
|
||||
remoteSocket, err := remoteGPGAgentSocket(sshClient)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localAddr := cookieAddr{
|
||||
Addr: &net.TCPAddr{
|
||||
IP: net.IPv4(127, 0, 0, 1),
|
||||
Port: int(port),
|
||||
},
|
||||
cookie: cookie,
|
||||
}
|
||||
remoteAddr := &net.UnixAddr{
|
||||
Name: remoteSocket,
|
||||
Net: "unix",
|
||||
}
|
||||
|
||||
return sshForwardRemote(ctx, stderr, sshClient, localAddr, remoteAddr)
|
||||
}
|
||||
|
||||
+1
-2
@@ -25,7 +25,6 @@ func start() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
before := time.Now()
|
||||
build, err := client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionStart,
|
||||
})
|
||||
@@ -33,7 +32,7 @@ func start() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID, before)
|
||||
err = cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStdout(), client, build.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
+4
-3
@@ -5,7 +5,6 @@ import (
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -17,6 +16,9 @@ func state() *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "state",
|
||||
Short: "Manually manage Terraform state to fix broken workspaces",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return cmd.Help()
|
||||
},
|
||||
}
|
||||
cmd.AddCommand(statePull(), statePush())
|
||||
return cmd
|
||||
@@ -97,7 +99,6 @@ func statePush() *cobra.Command {
|
||||
return err
|
||||
}
|
||||
|
||||
before := time.Now()
|
||||
build, err = client.CreateWorkspaceBuild(cmd.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
TemplateVersionID: build.TemplateVersionID,
|
||||
Transition: build.Transition,
|
||||
@@ -106,7 +107,7 @@ func statePush() *cobra.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStderr(), client, build.ID, before)
|
||||
return cliui.WorkspaceBuild(cmd.Context(), cmd.OutOrStderr(), client, build.ID)
|
||||
},
|
||||
}
|
||||
cmd.Flags().IntVarP(&buildNumber, "build", "b", 0, "Specify a workspace build to target by name.")
|
||||
|
||||
+6
-6
@@ -25,7 +25,7 @@ func TestStatePull(t *testing.T) {
|
||||
wantState := []byte("some state")
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
State: wantState,
|
||||
@@ -53,7 +53,7 @@ func TestStatePull(t *testing.T) {
|
||||
wantState := []byte("some state")
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
|
||||
Parse: echo.ParseComplete,
|
||||
Provision: []*proto.Provision_Response{{
|
||||
ProvisionApply: []*proto.Provision_Response{{
|
||||
Type: &proto.Provision_Response_Complete{
|
||||
Complete: &proto.Provision_Complete{
|
||||
State: wantState,
|
||||
@@ -82,8 +82,8 @@ func TestStatePush(t *testing.T) {
|
||||
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,
|
||||
Provision: echo.ProvisionComplete,
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
@@ -107,8 +107,8 @@ func TestStatePush(t *testing.T) {
|
||||
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,
|
||||
Provision: echo.ProvisionComplete,
|
||||
Parse: echo.ParseComplete,
|
||||
ProvisionApply: echo.ProvisionComplete,
|
||||
})
|
||||
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user