Compare commits
765 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 379ced672e | |||
| 971b1a87bd | |||
| 5133315792 | |||
| 683a7209b5 | |||
| 5246f8d142 | |||
| e6cd3005d3 | |||
| b80550957f | |||
| 894c758f06 | |||
| 33988fedcd | |||
| 2c8b264d78 | |||
| 339eebacae | |||
| 5cc5bbea04 | |||
| 62047e5f68 | |||
| 3b5cabb566 | |||
| a3ffab6ceb | |||
| 3fdeaf7b24 | |||
| 5c977c6be7 | |||
| b23e6a05c8 | |||
| fb28979537 | |||
| 3894bab038 | |||
| e4470e1617 | |||
| 9ea2f6f267 | |||
| 4be5b2ff98 | |||
| 8403dd5c36 | |||
| 4dcf5ef323 | |||
| aef400c2c5 | |||
| 9ef9044d9c | |||
| 2cffb55457 | |||
| 6cd1219289 | |||
| 575925c050 | |||
| bb3850adc2 | |||
| 86a82b5a2a | |||
| 718b30cada | |||
| b371bc89c0 | |||
| 676191643b | |||
| c6e44282b2 | |||
| d8ddd07ee5 | |||
| b7c574f679 | |||
| e086d7813b | |||
| c127d90efc | |||
| 326886d3c2 | |||
| ae522c558d | |||
| b8944074c4 | |||
| a3ebcd7a1e | |||
| b4f54f3eea | |||
| db4945dc27 | |||
| 661d22621a | |||
| 3338f32489 | |||
| 35017822d5 | |||
| 50124fefdc | |||
| 96e9a4f85c | |||
| 86f68b220e | |||
| db7b411094 | |||
| 6d992984a4 | |||
| 7c77a3cc83 | |||
| 07d1478f34 | |||
| 15f19431d7 | |||
| 2d5c068525 | |||
| dbe6b6c224 | |||
| b0c86220a7 | |||
| f2a12a06d1 | |||
| 115c52c5b0 | |||
| 4228c1f308 | |||
| 922f4c545f | |||
| 37885e2e82 | |||
| 20a3801600 | |||
| fccf6f1e0e | |||
| 6de59371ea | |||
| 1e5438eadb | |||
| c145f113fe | |||
| 5be02a293e | |||
| de3945c291 | |||
| bbc7b5085d | |||
| dda6bdc174 | |||
| d96adad56f | |||
| 45160c7679 | |||
| 45420b95f3 | |||
| ce21b2030a | |||
| be516f9686 | |||
| 370f0b9020 | |||
| 14d3e300d3 | |||
| 6ff9a05832 | |||
| ff1eabebe5 | |||
| 71393743dc | |||
| 5aa54be6ca | |||
| 910225698e | |||
| 335eb05223 | |||
| 4afce19fb7 | |||
| 705b9ccda8 | |||
| c330af0e4d | |||
| 5ed065d88d | |||
| 2df9a3e554 | |||
| 7ea8a2253e | |||
| c6bc7414aa | |||
| 9006b21758 | |||
| f5601cd783 | |||
| 7780087526 | |||
| 93b4675748 | |||
| 95fc962871 | |||
| 9dc8e0f4c5 | |||
| bfdc29f466 | |||
| bf87c97ede | |||
| 7ef6780d45 | |||
| 628563d94b | |||
| bacad93dde | |||
| c334d9c91a | |||
| f3b35c504f | |||
| 0664efbe2d | |||
| 168b4ff5ac | |||
| 9ecb9b967b | |||
| c44d013519 | |||
| 26ebd70b12 | |||
| 9f4972901c | |||
| 33e896d404 | |||
| efd532e1d7 | |||
| d6154c4310 | |||
| fb3523b37f | |||
| 6a846cdbb8 | |||
| 7de576b596 | |||
| 3301212972 | |||
| 5bd19f8ba3 | |||
| 1b5f3418d3 | |||
| 4f2202fe34 | |||
| c8580a415a | |||
| 40688e40df | |||
| 90b29df145 | |||
| 85cc695dc6 | |||
| 0787c42d32 | |||
| 914f35a3a3 | |||
| 328e69629c | |||
| 2a9234e9ba | |||
| d1db11ab21 | |||
| cb9d40fb8a | |||
| 9da646704b | |||
| eb646f036e | |||
| 25f1ddbf5e | |||
| 918bea18c1 | |||
| 6b9e1d4771 | |||
| 84d312cfea | |||
| 92b81c4164 | |||
| 0d6056633d | |||
| 8b1c46fbe0 | |||
| 0f342ed12f | |||
| 208a5beb95 | |||
| c2491746ba | |||
| c1bb5abcb7 | |||
| cd7ce8ecfb | |||
| 84922e239f | |||
| c3f0db3671 | |||
| 8f07d3357e | |||
| e6d8f674ad | |||
| bcf9bc3c90 | |||
| bd90740166 | |||
| 7b39f6b0d4 | |||
| 2e6dbd18b3 | |||
| 1958436b1d | |||
| 2ed88d593a | |||
| 5366f2576f | |||
| 8f85464fe6 | |||
| 01a904c133 | |||
| 093d243811 | |||
| 44210631cd | |||
| 242b1ea4ca | |||
| fcb0ce1f1b | |||
| 5cffac29da | |||
| 7c8c02733d | |||
| 48430625a0 | |||
| c74fed11ac | |||
| f23a05075e | |||
| 0eca1fcb8b | |||
| 2f18f4583b | |||
| 1d331dd049 | |||
| aa4a6f89ba | |||
| 903993a14a | |||
| 478121df77 | |||
| 2368f48c1c | |||
| 4c8a560e19 | |||
| 4eac2acede | |||
| 5bd5801286 | |||
| 0785b77d0b | |||
| 66c8060605 | |||
| 741d60a25e | |||
| 3a8424ea23 | |||
| 92253d0f52 | |||
| 7d15aad11a | |||
| e5d4f3557b | |||
| faf245234f | |||
| c9fcab3717 | |||
| ead8fae63d | |||
| 0ef85147cd | |||
| 0f8251be41 | |||
| 10c958bba1 | |||
| 043f4f5327 | |||
| 13e5c51c30 | |||
| 9596f236c1 | |||
| 0f414a00d3 | |||
| a74273f1fd | |||
| c90be9b0c1 | |||
| 851df91991 | |||
| 628750232f | |||
| 4672849d05 | |||
| d2a22c538b | |||
| 6bc93520c4 | |||
| cd38e297b6 | |||
| ef7fcf3930 | |||
| 49afab12d5 | |||
| 4b5c45d6df | |||
| e65eb0321c | |||
| 6dbfe6f7ae | |||
| 15d74a11a0 | |||
| f3ea740b27 | |||
| b96ac677f1 | |||
| 54fe082551 | |||
| 7667d64686 | |||
| f24cb5cc96 | |||
| c597c9260d | |||
| 839918c5e7 | |||
| 8c15192433 | |||
| b36d979a60 | |||
| f3c76ce244 | |||
| 63fe2305f8 | |||
| 47f2c7d683 | |||
| 0afff43f9d | |||
| 499769187b | |||
| 88d7181a47 | |||
| 83f9ea17b4 | |||
| 93eef7b542 | |||
| fb6b954222 | |||
| ded612d3ec | |||
| 6914862903 | |||
| c8eacc6df7 | |||
| af125c3795 | |||
| cb6a47227f | |||
| 4bd7fe8506 | |||
| 53e5746636 | |||
| a4d785dec5 | |||
| d4adfa3902 | |||
| 99e103e790 | |||
| 4cc26be5ec | |||
| 5710a98714 | |||
| b0084e2229 | |||
| d52bc91e48 | |||
| 337ee3544b | |||
| aeb4040958 | |||
| c818b4ddd4 | |||
| 046c1c4228 | |||
| 3e5cfa9e45 | |||
| fbec45b807 | |||
| cc944209ae | |||
| 82e6070c7a | |||
| 3514ca3476 | |||
| e8c59a1d9d | |||
| d7800a43e9 | |||
| 9f4f88f38c | |||
| a359879af5 | |||
| fa733318e0 | |||
| 6960d194ae | |||
| 9c8c6a952d | |||
| d9f419308a | |||
| b6d35edebd | |||
| 03f05e25f6 | |||
| cca4519420 | |||
| 2bef1752f1 | |||
| 40baa5bc72 | |||
| cf8be4eac5 | |||
| 0b2ba96065 | |||
| 6f9b3c1592 | |||
| f8f3d8967e | |||
| 4446d61fcd | |||
| 1c3dc8392e | |||
| fa59b30cfb | |||
| f007c90a30 | |||
| 10327fb3a9 | |||
| 755afa31cf | |||
| 422e044859 | |||
| c3ef7dc33b | |||
| d0f36dc6ba | |||
| cba6e93176 | |||
| bec6a26d0e | |||
| 8c4d726cf6 | |||
| fc3b2ff06c | |||
| 0613797934 | |||
| 363a016281 | |||
| 979430d635 | |||
| 7142cbb9e6 | |||
| 2c150d03f6 | |||
| 9b9496cf4d | |||
| a62e69d34a | |||
| 91a74f0ead | |||
| 4db8fa661e | |||
| 95a7c0c4f0 | |||
| db2d0596d4 | |||
| f2a96ac984 | |||
| 82cb6ef7ec | |||
| d15f16fa2e | |||
| 7b09d98238 | |||
| 83ccdaa755 | |||
| f619500833 | |||
| 8563b372e8 | |||
| 4fc047954e | |||
| 6f1951e1c8 | |||
| 86b9c97e8e | |||
| e978d4d9ac | |||
| c90e6d7b47 | |||
| 84fdfd2a18 | |||
| 712a1b50d8 | |||
| ccc664de37 | |||
| f1feb40e17 | |||
| 48f29a1995 | |||
| 6f9b1a39f4 | |||
| 60218c4c78 | |||
| 76722a7db5 | |||
| 4c7132f08b | |||
| 59a80d70dc | |||
| 9715ae5932 | |||
| 8af8c77e2a | |||
| 0338250d86 | |||
| 73402fc2f7 | |||
| ba4186dacc | |||
| 0b9ed57c10 | |||
| c648c548d8 | |||
| 21942afef3 | |||
| aaa5174bef | |||
| 591385f2ca | |||
| 27b8f201a4 | |||
| abbcffe181 | |||
| 9a47ea1279 | |||
| 6019d0ba96 | |||
| d6c4d47229 | |||
| 2e05329111 | |||
| 238e9956f4 | |||
| d79a7adf99 | |||
| f50e1d5a9a | |||
| 2c13797350 | |||
| d0feb70811 | |||
| b55a7a8b78 | |||
| 8c0565177e | |||
| c6076d2d0d | |||
| e09ad1ddc1 | |||
| 46becc7201 | |||
| 373b36c3c9 | |||
| 3b53f5ab47 | |||
| ff785588fe | |||
| fab196043e | |||
| 49feb12a7f | |||
| 89e6afbc5e | |||
| 58428aafce | |||
| 70a694ed4c | |||
| 097f739492 | |||
| 0ad5f6067d | |||
| 173dc0e35f | |||
| a77a9ab0a6 | |||
| 203f48af56 | |||
| b80d99550a | |||
| 4e0cb60eeb | |||
| dfeafa8f5a | |||
| efbd6257e4 | |||
| f9b660e573 | |||
| fce14fb9ad | |||
| 33beb9bd70 | |||
| 96642382b3 | |||
| 25c83cf0b1 | |||
| e398309a8f | |||
| e164b1e71c | |||
| 49a2880abc | |||
| 8acc7f2070 | |||
| 42336eef4a | |||
| dda9c56098 | |||
| e0351124b2 | |||
| ae40f8a82e | |||
| a3c45861bf | |||
| 500a789e05 | |||
| f3ff172979 | |||
| 98202b309e | |||
| 166467caf0 | |||
| e2cec454bc | |||
| 6e36082b0f | |||
| 4d4d27c509 | |||
| 6428a766a9 | |||
| 4242fd9c1b | |||
| 7619d1c49a | |||
| 76ce460cc4 | |||
| 83963b9e61 | |||
| 1d9162dc2f | |||
| 894020db6a | |||
| 78f1cdaebe | |||
| 7125b37545 | |||
| a27ac30e11 | |||
| d23670ad53 | |||
| 6d3f7fb2a2 | |||
| b0eaf4ca94 | |||
| a88e1cc5f8 | |||
| 1289937eae | |||
| 956d0cb042 | |||
| 7a4737cf76 | |||
| 5d42f4aa7b | |||
| c3390993dd | |||
| bf4b7abf14 | |||
| 56dfc64bb0 | |||
| cf1fcab514 | |||
| c6fb779c50 | |||
| c88ea26d7c | |||
| 893169c83b | |||
| 3209c863b8 | |||
| b7102b39af | |||
| 58b810fb0a | |||
| 22143d3e80 | |||
| f88a48df26 | |||
| 712662d014 | |||
| eacdfb9f9c | |||
| d8ddce8628 | |||
| d68340b125 | |||
| 68fa34feae | |||
| 37a859f071 | |||
| 96011e1b29 | |||
| 5b35f65305 | |||
| ce6ee9c6c6 | |||
| 6c2336b8e9 | |||
| 7ea1a4c686 | |||
| 915f69080a | |||
| 2279441517 | |||
| c082868f55 | |||
| 9f3c1c7367 | |||
| 4eb67ad98a | |||
| 615bb94ec4 | |||
| 6161d173d3 | |||
| ca83017dc1 | |||
| d488853393 | |||
| 88bc491778 | |||
| e8b3db8c7a | |||
| 4f01372179 | |||
| 652827f0e8 | |||
| 38b573857b | |||
| 15fda232b7 | |||
| 0d9615b4fd | |||
| ccb5b4df80 | |||
| b3a3671c6a | |||
| dac14fe581 | |||
| 7028ff79c3 | |||
| 177c7d3c68 | |||
| 0d453437de | |||
| a61c09e4dc | |||
| 3a614f1602 | |||
| ecc356f5a9 | |||
| b817c863ef | |||
| 0a07c7e554 | |||
| 695afb80e6 | |||
| 966c888e9d | |||
| 5a4dbcfc02 | |||
| a8e6e89f65 | |||
| 38c7dcda94 | |||
| d2b035312e | |||
| 0a71c34d46 | |||
| dd99457a04 | |||
| 005254d64a | |||
| 3c2c5ab7fc | |||
| 40b70dbdb0 | |||
| 88d2dbd994 | |||
| 03c5d42233 | |||
| 49d6d0f41b | |||
| 8beb0b131f | |||
| bac9b38e05 | |||
| dba23872eb | |||
| 554c4ab1eb | |||
| 40609c26e9 | |||
| c88e4162d8 | |||
| 492ab1cc7e | |||
| 943ea7c52a | |||
| 8d4bccc612 | |||
| 4dcbd7179f | |||
| aa6e6e3d58 | |||
| 6f20a64f9d | |||
| f975701b34 | |||
| 91cbe679c0 | |||
| fbd1d7f9a7 | |||
| 44924cd8d8 | |||
| 3e1fae7d3d | |||
| 80cbffe843 | |||
| f21f2dce57 | |||
| 70c5c47efd | |||
| 1f24aceea2 | |||
| a3f40d5ef8 | |||
| b697c6939a | |||
| a5e4bf38fe | |||
| 36454aa81b | |||
| ab59460e2c | |||
| 17626b8dd1 | |||
| 7a34a70cb8 | |||
| d6e2801478 | |||
| 0a73ae1036 | |||
| 6058bcdad8 | |||
| bece042fa8 | |||
| aaf295badf | |||
| b00f746cac | |||
| 9cbe2b27e7 | |||
| e4aef272fa | |||
| c6b7588933 | |||
| 1691768fb9 | |||
| de2585b0b6 | |||
| fd10ea1dcc | |||
| 687d9538de | |||
| bee913ac45 | |||
| f36b816391 | |||
| b2dab3308d | |||
| a6d66cc7ec | |||
| 90a6025e18 | |||
| 0787de88a9 | |||
| 2a297b073a | |||
| 2238593f57 | |||
| a588ec5b21 | |||
| 7bb3e0db4a | |||
| bf392ffea4 | |||
| 542fff7df0 | |||
| c8484b4fc8 | |||
| 70046ea08d | |||
| e8db21c89e | |||
| 38035da846 | |||
| 464e7979c4 | |||
| e00a80e029 | |||
| d4f0a22ac6 | |||
| f6cd002542 | |||
| d209c5ff99 | |||
| 7574a2d3ab | |||
| de1da93d04 | |||
| 03a8cc7d4e | |||
| d50ffa78f6 | |||
| 3894ae17a7 | |||
| 35a808f089 | |||
| b07e3069dd | |||
| 01b30eaa32 | |||
| af001773db | |||
| 879c61ce23 | |||
| 6bf7e5af91 | |||
| 8c33b028d2 | |||
| f9272046d5 | |||
| 266913a357 | |||
| c62512a8bb | |||
| a123badccc | |||
| 54898033b1 | |||
| c1440ac4f0 | |||
| 65e1d0af4b | |||
| ac6db5edf9 | |||
| 54055dc4cc | |||
| 407d263cd2 | |||
| 0c423f07a7 | |||
| 978364bd3e | |||
| 5cdfc08422 | |||
| 1f05a4a05e | |||
| 996863936a | |||
| 3085c4cf5b | |||
| 83a177e0c7 | |||
| b40d543cb5 | |||
| eafa8f5cb2 | |||
| 05fdb9c1f7 | |||
| 79b5d20cd2 | |||
| f9ca9c7a22 | |||
| 44cb400c8e | |||
| d9bdef915d | |||
| bdd2caf95d | |||
| 10aa32ca08 | |||
| fecc5b3027 | |||
| da8911426b | |||
| 7c41f957de | |||
| c2d44d16a3 | |||
| dd80958efb | |||
| ccf34901bc | |||
| 8778aa0f71 | |||
| ea675897fd | |||
| 940afa1ab1 | |||
| 07d41716ad | |||
| f6639b788f | |||
| e5268e4551 | |||
| a110d18275 | |||
| bfbf634bec | |||
| 80a2a5d6a8 | |||
| e40cc9314c | |||
| a114288ef2 | |||
| 5ea5db29e9 | |||
| 9ee53e5b4e | |||
| 21a923a7a0 | |||
| b1e7498e77 | |||
| 98c09bf5d2 | |||
| bde9fd58ea | |||
| 128674918b | |||
| b87c12ba92 | |||
| abc0ff9689 | |||
| b1ec4630f2 | |||
| 5bf46f360a | |||
| 4a0fd7466c | |||
| f26f123391 | |||
| 41e1383640 | |||
| 10c2817f4d | |||
| cd069faf01 | |||
| d977654f05 | |||
| 9b1d8f79ae | |||
| d8d86b16dc | |||
| 6c94dd4f23 | |||
| 2fde054e10 | |||
| 8735e234b4 | |||
| 472ea7ebbf | |||
| 6daf330d3a | |||
| 3cc86cf62d | |||
| 1a877716ca | |||
| 0a221e8d5b | |||
| 4213560b7a | |||
| 2a21b0d144 | |||
| 86ee75b672 | |||
| 0b8b227dcf | |||
| bda94bfc77 | |||
| 8b615f4522 | |||
| 093ec3d05b | |||
| 5a0afd8b7e | |||
| 22f2c6da4f | |||
| ce7f13c6c3 | |||
| 089f06886b | |||
| c94b5188bd | |||
| 5b59f2880f | |||
| c4f1676055 | |||
| 30c4b4db5c | |||
| 08e728bcb2 | |||
| 20e59e0797 | |||
| d5d8b918d7 | |||
| 8a3592582b | |||
| 87ad560aff | |||
| 58325dfd14 | |||
| fed668b432 | |||
| d7eadee4d7 | |||
| 9c1a6a29f2 | |||
| 46e1c36c42 | |||
| 0d2f14606b | |||
| 136900268e | |||
| 313d4e02d2 | |||
| 65b9f9bfd6 | |||
| 94639730f8 | |||
| 34c67e8428 | |||
| e4333c0433 | |||
| 8ccdf05bbc | |||
| 218f429336 | |||
| 6f4a9b6b51 | |||
| 3dec6ff32f | |||
| b9d83c75de | |||
| 7e20b56352 | |||
| 3d6c9799e3 | |||
| b4a5c7ffa9 | |||
| 7cb8bfb133 | |||
| 2cfadad023 | |||
| 9abaa94599 | |||
| 54e8f30002 | |||
| 5177f366f5 | |||
| 0e933f0537 | |||
| 3ef12ac284 | |||
| 75e7213ac2 | |||
| cbdaa63b68 | |||
| 714f2ef83c | |||
| 73a25c3bc5 | |||
| 819bfd3170 | |||
| 66a604d779 | |||
| 2ef2f97388 | |||
| 889daf200e | |||
| c4656d77cc | |||
| 495eea452f | |||
| 43e45f4ab7 | |||
| 57b38e5bb8 | |||
| 0793a4b35b | |||
| a1db6d809e | |||
| a1ec8ad6e9 | |||
| 8e06ad46d0 | |||
| 4699adee5e | |||
| 8923ce5216 | |||
| 02ffff11dd | |||
| 7049d7a881 | |||
| 84cdcac8ad | |||
| e987ad1d89 | |||
| 3a1fa04590 | |||
| d0b2f6196c | |||
| 1de023a121 | |||
| 1d3642d0be | |||
| 8c1bd32c33 | |||
| 07cd9acb2c | |||
| eed9794516 | |||
| 808e1c0d89 | |||
| 44d69139d5 | |||
| 87820a29d7 | |||
| c01d6fdf46 | |||
| fe240add86 | |||
| d04959cea8 | |||
| 3d30c8dc68 | |||
| 7d51515f9d | |||
| 87a172fb14 | |||
| c587af7c0e | |||
| 5d3f3c08cd | |||
| 0268c7a659 | |||
| 4b0b9b08d5 | |||
| 88eb6ce378 | |||
| fc09077b7b | |||
| d0fc81a51c | |||
| bbe23edc7d | |||
| de9e6889bb | |||
| 1ca5dc0328 | |||
| 28228f1bcb | |||
| 58bf0ec1c6 | |||
| ba7d1835e5 | |||
| 0c627a4cb9 | |||
| a11f8b003b | |||
| dd99897bb2 | |||
| 5ccf5084e8 | |||
| c9cca9d56e | |||
| 7958c52918 | |||
| 1f9bdc36bf | |||
| dd243686e4 | |||
| e7bea17e70 | |||
| 363dbad3a3 | |||
| 5b9a65e5c1 | |||
| c7e7312cb0 | |||
| e96652ebbc | |||
| 8326a3a675 | |||
| 7c081dcd6f | |||
| 0d65143301 | |||
| 056a697eff | |||
| 48ecee1025 | |||
| 7c3b8b6224 | |||
| e2b330fcba | |||
| 1adc19b41f | |||
| 4dfa901990 | |||
| a8a81a61cd | |||
| 44a70a5bc2 | |||
| 1131772e79 | |||
| e743588843 | |||
| 37676c46d5 | |||
| 7995d7c3d6 | |||
| f1b42a15fa | |||
| 8f62311f00 | |||
| fade8ba759 | |||
| 775fc3f5e9 | |||
| ffcfbb6c55 | |||
| 9c3fd5dd26 | |||
| 42324b386a | |||
| a4bba520a2 | |||
| 9a757f8e74 | |||
| 83ac386533 | |||
| 0ea89a3d41 | |||
| 213848e2e3 | |||
| 8435b70bea | |||
| 3b7f9534fb | |||
| e3206612e1 | |||
| 168d2d6ba0 | |||
| cd32c42699 | |||
| e527bc6242 | |||
| a51076a4cd | |||
| 78b8264a90 | |||
| c7233eccec | |||
| 40390ecc30 | |||
| 2806752c7d | |||
| e4ac691468 | |||
| 43ef00401c | |||
| 27f26910b6 | |||
| 0b019cad77 | |||
| 9d00a26a90 | |||
| 8cdd468107 | |||
| cb94dfb1f6 | |||
| 79fd736387 | |||
| 973cc2b875 | |||
| 24ba81930b | |||
| bf98b0dfe4 | |||
| b723da9e91 |
@@ -1,13 +1,13 @@
|
||||
{
|
||||
"name": "Development environments on your infrastructure",
|
||||
"image": "codercom/oss-dogfood:latest",
|
||||
"name": "Development environments on your infrastructure",
|
||||
"image": "codercom/oss-dogfood:latest",
|
||||
|
||||
"features": {
|
||||
// See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": "false"
|
||||
}
|
||||
},
|
||||
// SYS_PTRACE to enable go debugging
|
||||
"runArgs": ["--cap-add=SYS_PTRACE"]
|
||||
"features": {
|
||||
// See all possible options here https://github.com/devcontainers/features/tree/main/src/docker-in-docker
|
||||
"ghcr.io/devcontainers/features/docker-in-docker:2": {
|
||||
"moby": "false"
|
||||
}
|
||||
},
|
||||
// SYS_PTRACE to enable go debugging
|
||||
"runArgs": ["--cap-add=SYS_PTRACE"]
|
||||
}
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ trim_trailing_whitespace = true
|
||||
insert_final_newline = true
|
||||
indent_style = tab
|
||||
|
||||
[*.{md,json,yaml,yml,tf,tfvars,nix}]
|
||||
[*.{yaml,yml,tf,tfvars,nix}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
|
||||
|
||||
@@ -3,3 +3,5 @@
|
||||
|
||||
# chore: format code with semicolons when using prettier (#9555)
|
||||
988c9af0153561397686c119da9d1336d2433fdd
|
||||
# chore: use tabs for prettier and biome (#14283)
|
||||
95a7c0c4f087744a22c2e88dd3c5d30024d5fb02
|
||||
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
# Generated files
|
||||
coderd/apidoc/docs.go linguist-generated=true
|
||||
docs/api/*.md linguist-generated=true
|
||||
docs/cli/*.md linguist-generated=true
|
||||
docs/reference/api/*.md linguist-generated=true
|
||||
docs/reference/cli/*.md linguist-generated=true
|
||||
coderd/apidoc/swagger.json linguist-generated=true
|
||||
coderd/database/dump.sql linguist-generated=true
|
||||
peerbroker/proto/*.go linguist-generated=true
|
||||
|
||||
@@ -4,12 +4,12 @@ description: |
|
||||
inputs:
|
||||
version:
|
||||
description: "The Go version to use."
|
||||
default: "1.22.3"
|
||||
default: "1.22.5"
|
||||
runs:
|
||||
using: "composite"
|
||||
steps:
|
||||
- name: Setup Go
|
||||
uses: buildjet/setup-go@v5
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: ${{ inputs.version }}
|
||||
|
||||
|
||||
@@ -13,11 +13,11 @@ runs:
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3
|
||||
with:
|
||||
version: 8
|
||||
version: 9.6
|
||||
- name: Setup Node
|
||||
uses: buildjet/setup-node@v4.0.1
|
||||
uses: actions/setup-node@v4.0.3
|
||||
with:
|
||||
node-version: 18.19.0
|
||||
node-version: 20.16.0
|
||||
# See https://github.com/actions/setup-node#caching-global-packages-data
|
||||
cache: "pnpm"
|
||||
cache-dependency-path: ${{ inputs.directory }}/pnpm-lock.yaml
|
||||
|
||||
@@ -7,5 +7,5 @@ runs:
|
||||
- name: Install Terraform
|
||||
uses: hashicorp/setup-terraform@v3
|
||||
with:
|
||||
terraform_version: 1.7.5
|
||||
terraform_version: 1.9.2
|
||||
terraform_wrapper: false
|
||||
|
||||
+34
-37
@@ -39,6 +39,10 @@ updates:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
open-pull-requests-limit: 15
|
||||
groups:
|
||||
x:
|
||||
patterns:
|
||||
- "golang.org/x/*"
|
||||
ignore:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
@@ -61,7 +65,9 @@ updates:
|
||||
- dependency-name: "terraform"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/site/"
|
||||
directories:
|
||||
- "/site"
|
||||
- "/offlinedocs"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
@@ -71,44 +77,35 @@ updates:
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
groups:
|
||||
xterm:
|
||||
patterns:
|
||||
- "@xterm*"
|
||||
mui:
|
||||
patterns:
|
||||
- "@mui*"
|
||||
react:
|
||||
patterns:
|
||||
- "react"
|
||||
- "react-dom"
|
||||
- "@types/react"
|
||||
- "@types/react-dom"
|
||||
emotion:
|
||||
patterns:
|
||||
- "@emotion*"
|
||||
exclude-patterns:
|
||||
- "jest-runner-eslint"
|
||||
jest:
|
||||
patterns:
|
||||
- "jest"
|
||||
- "@types/jest"
|
||||
vite:
|
||||
patterns:
|
||||
- "vite*"
|
||||
- "@vitejs/plugin-react"
|
||||
ignore:
|
||||
# Ignore patch updates for all dependencies
|
||||
# Ignore major version updates to avoid breaking changes
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
# Ignore major updates to Node.js types, because they need to
|
||||
# correspond to the Node.js engine version
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
open-pull-requests-limit: 15
|
||||
groups:
|
||||
site:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/offlinedocs/"
|
||||
schedule:
|
||||
interval: "monthly"
|
||||
time: "06:00"
|
||||
timezone: "America/Chicago"
|
||||
reviewers:
|
||||
- "coder/ts"
|
||||
commit-message:
|
||||
prefix: "chore"
|
||||
labels: []
|
||||
ignore:
|
||||
# Ignore patch updates for all dependencies
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
- version-update:semver-patch
|
||||
# Ignore major updates to Node.js types, because they need to
|
||||
# correspond to the Node.js engine version
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
- version-update:semver-major
|
||||
groups:
|
||||
offlinedocs:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
+102
-82
@@ -117,29 +117,45 @@ jobs:
|
||||
run: |
|
||||
echo "${{ toJSON(steps.filter )}}"
|
||||
|
||||
update-flake:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.gomod == 'true'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
# Disabled due to instability. See: https://github.com/coder/coder/issues/14553
|
||||
# Re-enable once the flake hash calculation is stable.
|
||||
# update-flake:
|
||||
# needs: changes
|
||||
# if: needs.changes.outputs.gomod == 'true'
|
||||
# runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# steps:
|
||||
# - name: Checkout
|
||||
# uses: actions/checkout@v4
|
||||
# with:
|
||||
# fetch-depth: 1
|
||||
# # See: https://github.com/stefanzweifel/git-auto-commit-action?tab=readme-ov-file#commits-made-by-this-action-do-not-trigger-new-workflow-runs
|
||||
# token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
# - name: Setup Go
|
||||
# uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Update Nix Flake SRI Hash
|
||||
run: ./scripts/update-flake.sh
|
||||
# - name: Update Nix Flake SRI Hash
|
||||
# run: ./scripts/update-flake.sh
|
||||
|
||||
- name: Ensure No Changes
|
||||
run: git diff --exit-code
|
||||
# # auto update flake for dependabot
|
||||
# - uses: stefanzweifel/git-auto-commit-action@v5
|
||||
# if: github.actor == 'dependabot[bot]'
|
||||
# with:
|
||||
# # Allows dependabot to still rebase!
|
||||
# commit_message: "[dependabot skip] Update Nix Flake SRI Hash"
|
||||
# commit_user_name: "dependabot[bot]"
|
||||
# commit_user_email: "49699333+dependabot[bot]@users.noreply.github.com>"
|
||||
# commit_author: "dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>"
|
||||
|
||||
# # require everyone else to update it themselves
|
||||
# - name: Ensure No Changes
|
||||
# if: github.actor != 'dependabot[bot]'
|
||||
# run: git diff --exit-code
|
||||
|
||||
lint:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -154,13 +170,13 @@ jobs:
|
||||
|
||||
- name: Get golangci-lint cache dir
|
||||
run: |
|
||||
linter_ver=$(egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/Dockerfile | cut -d '=' -f 2)
|
||||
linter_ver=$(egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/contents/Dockerfile | cut -d '=' -f 2)
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$linter_ver
|
||||
dir=$(golangci-lint cache status | awk '/Dir/ { print $2 }')
|
||||
echo "LINT_CACHE_DIR=$dir" >> $GITHUB_ENV
|
||||
|
||||
- name: golangci-lint cache
|
||||
uses: buildjet/cache@v4
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.LINT_CACHE_DIR }}
|
||||
@@ -170,7 +186,7 @@ jobs:
|
||||
|
||||
# Check for any typos
|
||||
- name: Check for typos
|
||||
uses: crate-ci/typos@v1.21.0
|
||||
uses: crate-ci/typos@v1.24.6
|
||||
with:
|
||||
config: .github/workflows/typos.toml
|
||||
|
||||
@@ -191,9 +207,15 @@ jobs:
|
||||
run: |
|
||||
make --output-sync=line -j lint
|
||||
|
||||
- name: Check workflow files
|
||||
run: |
|
||||
bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) 1.6.22
|
||||
./actionlint -color -shellcheck= -ignore "set-output"
|
||||
shell: bash
|
||||
|
||||
gen:
|
||||
timeout-minutes: 8
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.docs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
@@ -243,7 +265,7 @@ jobs:
|
||||
fmt:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.offlinedocs-only == 'false' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 7
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -254,12 +276,9 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
# Use default Go version
|
||||
- name: Setup Go
|
||||
uses: buildjet/setup-go@v5
|
||||
with:
|
||||
# This doesn't need caching. It's super fast anyways!
|
||||
cache: false
|
||||
go-version: 1.21.9
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Install shfmt
|
||||
run: go install mvdan.cc/sh/v3/cmd/shfmt@v3.7.0
|
||||
@@ -273,7 +292,7 @@ jobs:
|
||||
run: ./scripts/check_unstaged.sh
|
||||
|
||||
test-go:
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'buildjet-4vcpu-ubuntu-2204' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
|
||||
runs-on: ${{ matrix.os == 'ubuntu-latest' && github.repository_owner == 'coder' && 'depot-ubuntu-22.04-4' || matrix.os == 'macos-latest' && github.repository_owner == 'coder' && 'macos-latest-xlarge' || matrix.os == 'windows-2022' && github.repository_owner == 'coder' && 'windows-latest-16-cores' || matrix.os }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -329,7 +348,7 @@ jobs:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
test-go-pg:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
@@ -351,8 +370,50 @@ jobs:
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_VERSION: "13"
|
||||
TS_DEBUG_DISCO: "true"
|
||||
run: |
|
||||
make test-postgres
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
timeout-minutes: 1
|
||||
continue-on-error: true
|
||||
uses: ./.github/actions/upload-datadog
|
||||
if: success() || failure()
|
||||
with:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
# NOTE: this could instead be defined as a matrix strategy, but we want to
|
||||
# only block merging if tests on postgres 13 fail. Using a matrix strategy
|
||||
# here makes the check in the above `required` job rather complicated.
|
||||
test-go-pg-16:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs:
|
||||
- changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
# This timeout must be greater than the timeout set by `go test` in
|
||||
# `make test-postgres` to ensure we receive a trace of running
|
||||
# goroutines. Setting this to the timeout +5m should work quite well
|
||||
# even if some of the preceding steps are slow.
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Setup Go
|
||||
uses: ./.github/actions/setup-go
|
||||
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Test with PostgreSQL Database
|
||||
env:
|
||||
POSTGRES_VERSION: "16"
|
||||
TS_DEBUG_DISCO: "true"
|
||||
run: |
|
||||
export TS_DEBUG_DISCO=true
|
||||
make test-postgres
|
||||
|
||||
- name: Upload test stats to Datadog
|
||||
@@ -364,7 +425,7 @@ jobs:
|
||||
api-key: ${{ secrets.DATADOG_API_KEY }}
|
||||
|
||||
test-go-race:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 25
|
||||
@@ -399,7 +460,7 @@ jobs:
|
||||
# These tests are skipped in the main go test jobs because they require root
|
||||
# and mess with networking.
|
||||
test-go-tailnet-integration:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
# Unnecessary to run on main for now
|
||||
if: needs.changes.outputs.tailnet-integration == 'true' || needs.changes.outputs.ci == 'true'
|
||||
@@ -421,7 +482,7 @@ jobs:
|
||||
run: make test-tailnet-integration
|
||||
|
||||
test-js:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -438,7 +499,7 @@ jobs:
|
||||
working-directory: site
|
||||
|
||||
test-e2e:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-16vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-16' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.go == 'true' || needs.changes.outputs.ts == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
timeout-minutes: 20
|
||||
@@ -582,7 +643,7 @@ jobs:
|
||||
offlinedocs:
|
||||
name: offlinedocs
|
||||
needs: changes
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
if: needs.changes.outputs.offlinedocs == 'true' || needs.changes.outputs.ci == 'true' || needs.changes.outputs.docs == 'true'
|
||||
|
||||
steps:
|
||||
@@ -650,7 +711,6 @@ jobs:
|
||||
- test-e2e
|
||||
- offlinedocs
|
||||
- sqlc-vet
|
||||
- dependency-license-review
|
||||
# Allow this job to run even if the needed jobs fail, are skipped or
|
||||
# cancelled.
|
||||
if: always()
|
||||
@@ -667,7 +727,6 @@ jobs:
|
||||
echo "- test-js: ${{ needs.test-js.result }}"
|
||||
echo "- test-e2e: ${{ needs.test-e2e.result }}"
|
||||
echo "- offlinedocs: ${{ needs.offlinedocs.result }}"
|
||||
echo "- dependency-license-review: ${{ needs.dependency-license-review.result }}"
|
||||
echo
|
||||
|
||||
# We allow skipped jobs to pass, but not failed or cancelled jobs.
|
||||
@@ -680,11 +739,10 @@ jobs:
|
||||
|
||||
build:
|
||||
# This builds and publishes ghcr.io/coder/coder-preview:main for each commit
|
||||
# to main branch. We are only building this for amd64 platform. (>95% pulls
|
||||
# are for amd64)
|
||||
# to main branch.
|
||||
needs: changes
|
||||
if: github.ref == 'refs/heads/main' && needs.changes.outputs.docs-only == 'false' && !github.event.pull_request.head.repo.fork
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
env:
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
outputs:
|
||||
@@ -744,13 +802,15 @@ jobs:
|
||||
echo "tag=$tag" >> $GITHUB_OUTPUT
|
||||
|
||||
# build images for each architecture
|
||||
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
# note: omitting the -j argument to avoid race conditions when pushing
|
||||
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
|
||||
# only push if we are on main branch
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
# build and push multi-arch manifest, this depends on the other images
|
||||
# being pushed so will automatically push them
|
||||
make -j push/build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
# note: omitting the -j argument to avoid race conditions when pushing
|
||||
make push/build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
|
||||
# Define specific tags
|
||||
tags=("$tag" "main" "latest")
|
||||
@@ -890,7 +950,7 @@ jobs:
|
||||
# runs sqlc-vet to ensure all queries are valid. This catches any mistakes
|
||||
# in migrations or sqlc queries that makes a query unable to be prepared.
|
||||
sqlc-vet:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
needs: changes
|
||||
if: needs.changes.outputs.db == 'true' || needs.changes.outputs.ci == 'true' || github.ref == 'refs/heads/main'
|
||||
steps:
|
||||
@@ -908,43 +968,3 @@ jobs:
|
||||
- name: Setup and run sqlc vet
|
||||
run: |
|
||||
make sqlc-vet
|
||||
|
||||
# dependency-license-review checks that no license-incompatible dependencies have been introduced.
|
||||
# This action is not intended to do a vulnerability check since that is handled by a separate action.
|
||||
dependency-license-review:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.ref != 'refs/heads/main' && github.actor != 'dependabot[bot]'
|
||||
steps:
|
||||
- name: "Checkout Repository"
|
||||
uses: actions/checkout@v4
|
||||
- name: "Dependency Review"
|
||||
id: review
|
||||
uses: actions/dependency-review-action@v4.3.2
|
||||
with:
|
||||
allow-licenses: Apache-2.0, BSD-2-Clause, BSD-3-Clause, CC0-1.0, ISC, MIT, MIT-0, MPL-2.0
|
||||
allow-dependencies-licenses: "pkg:golang/github.com/coder/wgtunnel@0.1.13-0.20240522110300-ade90dfb2da0"
|
||||
license-check: true
|
||||
vulnerability-check: false
|
||||
- name: "Report"
|
||||
# make sure this step runs even if the previous failed
|
||||
if: always()
|
||||
shell: bash
|
||||
env:
|
||||
VULNERABLE_CHANGES: ${{ steps.review.outputs.invalid-license-changes }}
|
||||
run: |
|
||||
fields=( "unlicensed" "unresolved" "forbidden" )
|
||||
|
||||
# This is unfortunate that we have to do this but the action does not support failing on
|
||||
# an unknown license. The unknown dependency could easily have a GPL license which
|
||||
# would be problematic for us.
|
||||
# Track https://github.com/actions/dependency-review-action/issues/672 for when
|
||||
# we can remove this brittle workaround.
|
||||
for field in "${fields[@]}"; do
|
||||
# Use jq to check if the array is not empty
|
||||
if [[ $(echo "$VULNERABLE_CHANGES" | jq ".${field} | length") -ne 0 ]]; then
|
||||
echo "Invalid or unknown licenses detected, contact @sreya to ensure your added dependency falls under one of our allowed licenses."
|
||||
echo "$VULNERABLE_CHANGES" | jq
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
echo "No incompatible licenses detected"
|
||||
|
||||
@@ -13,6 +13,8 @@ on:
|
||||
- opened
|
||||
- reopened
|
||||
- edited
|
||||
# For jobs that don't run on draft PRs.
|
||||
- ready_for_review
|
||||
|
||||
# Only run one instance per PR to ensure in-order execution.
|
||||
concurrency: pr-${{ github.ref }}
|
||||
@@ -34,7 +36,7 @@ jobs:
|
||||
steps:
|
||||
- name: cla
|
||||
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.4.0
|
||||
uses: contributor-assistant/github-action@v2.6.0
|
||||
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
|
||||
@@ -52,7 +54,7 @@ jobs:
|
||||
release-labels:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip tagging for draft PRs.
|
||||
if: ${{ github.event_name == 'pull_request_target' && success() && !github.event.pull_request.draft }}
|
||||
if: ${{ github.event_name == 'pull_request_target' && !github.event.pull_request.draft }}
|
||||
steps:
|
||||
- name: release-labels
|
||||
uses: actions/github-script@v7
|
||||
|
||||
@@ -17,8 +17,13 @@ on:
|
||||
- "flake.nix"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
# Necessary for GCP authentication (https://github.com/google-github-actions/setup-gcloud#usage)
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
build_image:
|
||||
if: github.actor != 'dependabot[bot]' # Skip Dependabot PRs
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -55,7 +60,7 @@ jobs:
|
||||
project: b4q6ltmpzh
|
||||
token: ${{ secrets.DEPOT_TOKEN }}
|
||||
buildx-fallback: true
|
||||
context: "{{defaultContext}}:dogfood"
|
||||
context: "{{defaultContext}}:dogfood/contents"
|
||||
pull: true
|
||||
save: true
|
||||
push: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -68,7 +73,7 @@ jobs:
|
||||
token: ${{ secrets.DEPOT_TOKEN }}
|
||||
buildx-fallback: true
|
||||
context: "."
|
||||
file: "dogfood/Dockerfile.nix"
|
||||
file: "dogfood/contents/Dockerfile.nix"
|
||||
pull: true
|
||||
save: true
|
||||
push: ${{ github.ref == 'refs/heads/main' }}
|
||||
@@ -84,11 +89,20 @@ jobs:
|
||||
- name: Setup Terraform
|
||||
uses: ./.github/actions/setup-tf
|
||||
|
||||
- name: Authenticate to Google Cloud
|
||||
uses: google-github-actions/auth@v2
|
||||
with:
|
||||
workload_identity_provider: projects/573722524737/locations/global/workloadIdentityPools/github/providers/github
|
||||
service_account: coder-ci@coder-dogfood.iam.gserviceaccount.com
|
||||
|
||||
- name: Terraform init and validate
|
||||
run: |
|
||||
cd dogfood
|
||||
terraform init -upgrade
|
||||
terraform validate
|
||||
cd contents
|
||||
terraform init -upgrade
|
||||
terraform validate
|
||||
|
||||
- name: Get short commit SHA
|
||||
if: github.ref == 'refs/heads/main'
|
||||
@@ -100,22 +114,18 @@ jobs:
|
||||
id: message
|
||||
run: echo "pr_title=$(git log --format=%s -n 1 ${{ github.sha }})" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: "Get latest Coder binary from the server"
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
curl -fsSL "https://dev.coder.com/bin/coder-linux-amd64" -o "./coder"
|
||||
chmod +x "./coder"
|
||||
|
||||
- name: "Push template"
|
||||
if: github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
./coder templates push $CODER_TEMPLATE_NAME --directory $CODER_TEMPLATE_DIR --yes --name=$CODER_TEMPLATE_VERSION --message="$CODER_TEMPLATE_MESSAGE"
|
||||
cd dogfood
|
||||
terraform apply -auto-approve
|
||||
env:
|
||||
# Consumed by Coder CLI
|
||||
# Consumed by coderd provider
|
||||
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
|
||||
CODER_TEMPLATE_MESSAGE: ${{ steps.message.outputs.pr_title }}
|
||||
TF_VAR_CODER_TEMPLATE_NAME: ${{ secrets.CODER_TEMPLATE_NAME }}
|
||||
TF_VAR_CODER_TEMPLATE_VERSION: ${{ steps.vars.outputs.sha_short }}
|
||||
TF_VAR_CODER_TEMPLATE_DIR: ./contents
|
||||
TF_VAR_CODER_TEMPLATE_MESSAGE: ${{ steps.message.outputs.pr_title }}
|
||||
TF_LOG: info
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
{
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "://.*.?example\\.com"
|
||||
},
|
||||
{
|
||||
"pattern": "developer.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "docs.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "support.google.com"
|
||||
},
|
||||
{
|
||||
"pattern": "tailscale.com"
|
||||
},
|
||||
{
|
||||
"pattern": "wireguard.com"
|
||||
}
|
||||
],
|
||||
"aliveStatusCodes": [200, 0]
|
||||
"ignorePatterns": [
|
||||
{
|
||||
"pattern": "://localhost"
|
||||
},
|
||||
{
|
||||
"pattern": "://.*.?example\\.com"
|
||||
},
|
||||
{
|
||||
"pattern": "developer.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "docs.github.com"
|
||||
},
|
||||
{
|
||||
"pattern": "support.google.com"
|
||||
},
|
||||
{
|
||||
"pattern": "tailscale.com"
|
||||
},
|
||||
{
|
||||
"pattern": "wireguard.com"
|
||||
}
|
||||
],
|
||||
"aliveStatusCodes": [200, 0]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ jobs:
|
||||
# While GitHub's toaster runners are likelier to flake, we want consistency
|
||||
# between this environment and the regular test environment for DataDog
|
||||
# statistics and to only show real workflow threats.
|
||||
runs-on: "buildjet-8vcpu-ubuntu-2204"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# This runner costs 0.016 USD per minute,
|
||||
# so 0.016 * 240 = 3.84 USD per run.
|
||||
timeout-minutes: 240
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
|
||||
go-timing:
|
||||
# We run these tests with p=1 so we don't need a lot of compute.
|
||||
runs-on: "buildjet-2vcpu-ubuntu-2204"
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
|
||||
@@ -14,4 +14,4 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Assign author
|
||||
uses: toshimaru/auto-author-assign@v2.1.0
|
||||
uses: toshimaru/auto-author-assign@v2.1.1
|
||||
|
||||
@@ -101,7 +101,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
|
||||
chmod 644 ~/.kube/config
|
||||
export KUBECONFIG=~/.kube/config
|
||||
|
||||
@@ -189,7 +189,7 @@ jobs:
|
||||
needs: get_info
|
||||
# Run build job only if there are changes in the files that we care about or if the workflow is manually triggered with --build flag
|
||||
if: needs.get_info.outputs.BUILD == 'true'
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
# This concurrency only cancels build jobs if a new build is triggred. It will avoid cancelling the current deployemtn in case of docs chnages.
|
||||
concurrency:
|
||||
group: build-${{ github.workflow }}-${{ github.ref }}-${{ needs.get_info.outputs.BUILD }}
|
||||
@@ -253,7 +253,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p ~/.kube
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG }}" > ~/.kube/config
|
||||
echo "${{ secrets.PR_DEPLOYMENTS_KUBECONFIG_BASE64 }}" | base64 --decode > ~/.kube/config
|
||||
chmod 644 ~/.kube/config
|
||||
export KUBECONFIG=~/.kube/config
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: release-validation
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
jobs:
|
||||
network-performance:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Run Schmoder CI
|
||||
uses: benc-uk/workflow-dispatch@v1.2.4
|
||||
with:
|
||||
workflow: ci.yaml
|
||||
repo: coder/schmoder
|
||||
inputs: '{ "num_releases": "3", "commit": "${{ github.sha }}" }'
|
||||
token: ${{ secrets.CDRCI_SCHMODER_ACTIONS_TOKEN }}
|
||||
ref: main
|
||||
@@ -39,7 +39,7 @@ env:
|
||||
jobs:
|
||||
release:
|
||||
name: Build and publish
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
env:
|
||||
# Necessary for Docker manifest
|
||||
DOCKER_CLI_EXPERIMENTAL: "enabled"
|
||||
@@ -180,7 +180,7 @@ jobs:
|
||||
|
||||
- name: Test migrations from current ref to main
|
||||
run: |
|
||||
make test-migrations
|
||||
POSTGRES_VERSION=13 make test-migrations
|
||||
|
||||
# Setup GCloud for signing Windows binaries.
|
||||
- name: Authenticate to Google Cloud
|
||||
@@ -297,7 +297,7 @@ jobs:
|
||||
|
||||
# build Docker images for each architecture
|
||||
version="$(./scripts/version.sh)"
|
||||
make -j build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
make build/coder_"$version"_linux_{amd64,arm64,armv7}.tag
|
||||
|
||||
# we can't build multi-arch if the images aren't pushed, so quit now
|
||||
# if dry-running
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
|
||||
# build and push multi-arch manifest, this depends on the other images
|
||||
# being pushed so will automatically push them.
|
||||
make -j push/build/coder_"$version"_linux.tag
|
||||
make push/build/coder_"$version"_linux.tag
|
||||
|
||||
# if the current version is equal to the highest (according to semver)
|
||||
# version in the repo, also create a multi-arch image as ":latest" and
|
||||
@@ -396,14 +396,14 @@ jobs:
|
||||
./build/*.rpm
|
||||
retention-days: 7
|
||||
|
||||
- name: Start Packer builds
|
||||
- name: Send repository-dispatch event
|
||||
if: ${{ !inputs.dry_run }}
|
||||
uses: peter-evans/repository-dispatch@v3
|
||||
with:
|
||||
token: ${{ secrets.CDRCI_GITHUB_TOKEN }}
|
||||
repository: coder/packages
|
||||
event-type: coder-release
|
||||
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}"}'
|
||||
client-payload: '{"coder_version": "${{ steps.version.outputs.version }}", "release_channel": "${{ inputs.release_channel }}"}'
|
||||
|
||||
publish-homebrew:
|
||||
name: Publish to Homebrew tap
|
||||
|
||||
@@ -23,7 +23,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
codeql:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
"${{ secrets.SLACK_SECURITY_FAILURE_WEBHOOK_URL }}"
|
||||
|
||||
trivy:
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'buildjet-8vcpu-ubuntu-2204' || 'ubuntu-latest' }}
|
||||
runs-on: ${{ github.repository_owner == 'coder' && 'depot-ubuntu-22.04-8' || 'ubuntu-latest' }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
echo "image=$(cat "$image_job")" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Run Trivy vulnerability scanner
|
||||
uses: aquasecurity/trivy-action@fd25fed6972e341ff0007ddb61f77e88103953c2
|
||||
uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8
|
||||
with:
|
||||
image-ref: ${{ steps.build.outputs.image }}
|
||||
format: sarif
|
||||
|
||||
@@ -17,7 +17,10 @@ jobs:
|
||||
with:
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
days-before-stale: 180
|
||||
# days-before-stale: 180
|
||||
# essentially disabled for now while we work through polish issues
|
||||
days-before-stale: 3650
|
||||
|
||||
# Pull Requests become stale more quickly due to merge conflicts.
|
||||
# Also, we promote minimizing WIP.
|
||||
days-before-pr-stale: 7
|
||||
|
||||
@@ -14,8 +14,14 @@ darcula = "darcula"
|
||||
Hashi = "Hashi"
|
||||
trialer = "trialer"
|
||||
encrypter = "encrypter"
|
||||
hel = "hel" # as in helsinki
|
||||
pn = "pn" # this is used as proto node
|
||||
# as in helsinki
|
||||
hel = "hel"
|
||||
# this is used as proto node
|
||||
pn = "pn"
|
||||
# typos doesn't like the EDE in TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA
|
||||
EDE = "EDE"
|
||||
# HELO is an SMTP command
|
||||
HELO = "HELO"
|
||||
|
||||
[files]
|
||||
extend-exclude = [
|
||||
@@ -33,4 +39,5 @@ extend-exclude = [
|
||||
"**/pnpm-lock.yaml",
|
||||
"tailnet/testdata/**",
|
||||
"site/src/pages/SetupPage/countries.tsx",
|
||||
"provisioner/terraform/testdata/**",
|
||||
]
|
||||
|
||||
@@ -68,3 +68,6 @@ result
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
|
||||
@@ -195,6 +195,11 @@ linters-settings:
|
||||
- name: var-naming
|
||||
- name: waitgroup-by-value
|
||||
|
||||
# irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview
|
||||
govet:
|
||||
disable:
|
||||
- loopclosure
|
||||
|
||||
issues:
|
||||
# Rules listed here: https://github.com/securego/gosec#available-rules
|
||||
exclude-rules:
|
||||
|
||||
+9
-12
@@ -71,24 +71,21 @@ result
|
||||
|
||||
# Filebrowser.db
|
||||
**/filebrowser.db
|
||||
|
||||
# pnpm
|
||||
.pnpm-store/
|
||||
# .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
|
||||
enterprise/tailnet/testdata/*.golden.html
|
||||
tailnet/testdata/*.golden.html
|
||||
|
||||
# Generated files shouldn't be formatted.
|
||||
site/e2e/provisionerGenerated.ts
|
||||
testdata/
|
||||
|
||||
# Ignore generated files
|
||||
**/pnpm-lock.yaml
|
||||
|
||||
# Ignore generated JSON (e.g. examples/examples.gen.json).
|
||||
**/*.gen.json
|
||||
|
||||
# Everything in site/ is formatted by Biome. For the rest of the repo though, we
|
||||
# need broader language support.
|
||||
site/
|
||||
|
||||
+6
-12
@@ -2,19 +2,13 @@
|
||||
# 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
|
||||
enterprise/tailnet/testdata/*.golden.html
|
||||
tailnet/testdata/*.golden.html
|
||||
|
||||
# Generated files shouldn't be formatted.
|
||||
site/e2e/provisionerGenerated.ts
|
||||
testdata/
|
||||
|
||||
# Ignore generated files
|
||||
**/pnpm-lock.yaml
|
||||
|
||||
# Ignore generated JSON (e.g. examples/examples.gen.json).
|
||||
**/*.gen.json
|
||||
|
||||
# Everything in site/ is formatted by Biome. For the rest of the repo though, we
|
||||
# need broader language support.
|
||||
site/
|
||||
|
||||
+3
-3
@@ -4,13 +4,13 @@
|
||||
printWidth: 80
|
||||
proseWrap: always
|
||||
trailingComma: all
|
||||
useTabs: false
|
||||
useTabs: true
|
||||
tabWidth: 2
|
||||
overrides:
|
||||
- files:
|
||||
- README.md
|
||||
- docs/api/**/*.md
|
||||
- docs/cli/**/*.md
|
||||
- docs/reference/api/**/*.md
|
||||
- docs/reference/cli/**/*.md
|
||||
- docs/changelogs/*.md
|
||||
- .github/**/*.{yaml,yml,toml}
|
||||
- scripts/**/*.{yaml,yml,toml}
|
||||
|
||||
Vendored
+13
-13
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"github.vscode-codeql",
|
||||
"golang.go",
|
||||
"hashicorp.terraform",
|
||||
"esbenp.prettier-vscode",
|
||||
"foxundermoon.shell-format",
|
||||
"emeraldwalk.runonsave",
|
||||
"zxh404.vscode-proto3",
|
||||
"redhat.vscode-yaml",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig"
|
||||
]
|
||||
"recommendations": [
|
||||
"github.vscode-codeql",
|
||||
"golang.go",
|
||||
"hashicorp.terraform",
|
||||
"esbenp.prettier-vscode",
|
||||
"foxundermoon.shell-format",
|
||||
"emeraldwalk.runonsave",
|
||||
"zxh404.vscode-proto3",
|
||||
"redhat.vscode-yaml",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"EditorConfig.EditorConfig",
|
||||
"biomejs.biome"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+237
-225
@@ -1,227 +1,239 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"afero",
|
||||
"agentsdk",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"authcheck",
|
||||
"autostop",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
"buildname",
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"codecov",
|
||||
"coderd",
|
||||
"coderdenttest",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"contravariance",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbgen",
|
||||
"dbmem",
|
||||
"dbtype",
|
||||
"DERP",
|
||||
"derphttp",
|
||||
"derpmap",
|
||||
"devel",
|
||||
"devtunnel",
|
||||
"dflags",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
"drpcmux",
|
||||
"drpcserver",
|
||||
"Dsts",
|
||||
"embeddedpostgres",
|
||||
"enablements",
|
||||
"enterprisemeta",
|
||||
"errgroup",
|
||||
"eventsourcemock",
|
||||
"externalauth",
|
||||
"Failf",
|
||||
"fatih",
|
||||
"Formik",
|
||||
"gitauth",
|
||||
"gitsshkey",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
"goleak",
|
||||
"gonet",
|
||||
"gossh",
|
||||
"gsyslog",
|
||||
"GTTY",
|
||||
"hashicorp",
|
||||
"hclsyntax",
|
||||
"httpapi",
|
||||
"httpmw",
|
||||
"idtoken",
|
||||
"Iflag",
|
||||
"incpatch",
|
||||
"initialisms",
|
||||
"ipnstate",
|
||||
"isatty",
|
||||
"Jobf",
|
||||
"Keygen",
|
||||
"kirsle",
|
||||
"Kubernetes",
|
||||
"ldflags",
|
||||
"magicsock",
|
||||
"manifoldco",
|
||||
"mapstructure",
|
||||
"mattn",
|
||||
"mitchellh",
|
||||
"moby",
|
||||
"namesgenerator",
|
||||
"namespacing",
|
||||
"netaddr",
|
||||
"netip",
|
||||
"netmap",
|
||||
"netns",
|
||||
"netstack",
|
||||
"nettype",
|
||||
"nfpms",
|
||||
"nhooyr",
|
||||
"nmcfg",
|
||||
"nolint",
|
||||
"nosec",
|
||||
"ntqry",
|
||||
"OIDC",
|
||||
"oneof",
|
||||
"opty",
|
||||
"paralleltest",
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promhttp",
|
||||
"protobuf",
|
||||
"provisionerd",
|
||||
"provisionerdserver",
|
||||
"provisionersdk",
|
||||
"ptty",
|
||||
"ptys",
|
||||
"ptytest",
|
||||
"quickstart",
|
||||
"reconfig",
|
||||
"replicasync",
|
||||
"retrier",
|
||||
"rpty",
|
||||
"SCIM",
|
||||
"sdkproto",
|
||||
"sdktrace",
|
||||
"Signup",
|
||||
"slogtest",
|
||||
"sourcemapped",
|
||||
"spinbutton",
|
||||
"Srcs",
|
||||
"stdbuf",
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
"tailnet",
|
||||
"tailnettest",
|
||||
"Tailscale",
|
||||
"tanstack",
|
||||
"tbody",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
"TCSETS",
|
||||
"templateversions",
|
||||
"testdata",
|
||||
"testid",
|
||||
"testutil",
|
||||
"tfexec",
|
||||
"tfjson",
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"thead",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tokenconfig",
|
||||
"Topbar",
|
||||
"tparallel",
|
||||
"trialer",
|
||||
"trimprefix",
|
||||
"tsdial",
|
||||
"tslogger",
|
||||
"tstun",
|
||||
"turnconn",
|
||||
"typegen",
|
||||
"typesafe",
|
||||
"unconvert",
|
||||
"Untar",
|
||||
"Userspace",
|
||||
"VMID",
|
||||
"walkthrough",
|
||||
"weblinks",
|
||||
"webrtc",
|
||||
"wgcfg",
|
||||
"wgconfig",
|
||||
"wgengine",
|
||||
"wgmonitor",
|
||||
"wgnet",
|
||||
"workspaceagent",
|
||||
"workspaceagents",
|
||||
"workspaceapp",
|
||||
"workspaceapps",
|
||||
"workspacebuilds",
|
||||
"workspacename",
|
||||
"wsjson",
|
||||
"xerrors",
|
||||
"xlarge",
|
||||
"xsmall",
|
||||
"yamux"
|
||||
],
|
||||
"cSpell.ignorePaths": ["site/package.json", ".vscode/settings.json"],
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
"match": "database/queries/*.sql",
|
||||
"cmd": "make gen"
|
||||
},
|
||||
{
|
||||
"match": "provisionerd/proto/provisionerd.proto",
|
||||
"cmd": "make provisionerd/proto/provisionerd.pb.go"
|
||||
}
|
||||
]
|
||||
},
|
||||
"eslint.workingDirectories": ["./site"],
|
||||
"search.exclude": {
|
||||
"**.pb.go": true,
|
||||
"**/*.gen.json": true,
|
||||
"**/testdata/*": true,
|
||||
"coderd/apidoc/**": true,
|
||||
"docs/api/*.md": true,
|
||||
"docs/templates/*.md": true,
|
||||
"LICENSE": true,
|
||||
"scripts/metricsdocgen/metrics": true,
|
||||
"site/out/**": true,
|
||||
"site/storybook-static/**": true,
|
||||
"**.map": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
// Ensure files always have a newline.
|
||||
"files.insertFinalNewline": true,
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast"],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredGutterStyle": "blockgreen",
|
||||
"uncoveredGutterStyle": "blockred"
|
||||
},
|
||||
// The codersdk is used by coderd another other packages extensively.
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
// Since package coverage pairing can't be defined, all packages cover
|
||||
// all other packages.
|
||||
"go.testFlags": ["-short", "-coverpkg=./..."],
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib",
|
||||
// Playwright tests in VSCode will open a browser to live "view" the test.
|
||||
"playwright.reuseBrowser": true
|
||||
"cSpell.words": [
|
||||
"afero",
|
||||
"agentsdk",
|
||||
"apps",
|
||||
"ASKPASS",
|
||||
"authcheck",
|
||||
"autostop",
|
||||
"awsidentity",
|
||||
"bodyclose",
|
||||
"buildinfo",
|
||||
"buildname",
|
||||
"circbuf",
|
||||
"cliflag",
|
||||
"cliui",
|
||||
"codecov",
|
||||
"coderd",
|
||||
"coderdenttest",
|
||||
"coderdtest",
|
||||
"codersdk",
|
||||
"contravariance",
|
||||
"cronstrue",
|
||||
"databasefake",
|
||||
"dbgen",
|
||||
"dbmem",
|
||||
"dbtype",
|
||||
"DERP",
|
||||
"derphttp",
|
||||
"derpmap",
|
||||
"devel",
|
||||
"devtunnel",
|
||||
"dflags",
|
||||
"drpc",
|
||||
"drpcconn",
|
||||
"drpcmux",
|
||||
"drpcserver",
|
||||
"Dsts",
|
||||
"embeddedpostgres",
|
||||
"enablements",
|
||||
"enterprisemeta",
|
||||
"errgroup",
|
||||
"eventsourcemock",
|
||||
"externalauth",
|
||||
"Failf",
|
||||
"fatih",
|
||||
"Formik",
|
||||
"gitauth",
|
||||
"gitsshkey",
|
||||
"goarch",
|
||||
"gographviz",
|
||||
"goleak",
|
||||
"gonet",
|
||||
"gossh",
|
||||
"gsyslog",
|
||||
"GTTY",
|
||||
"hashicorp",
|
||||
"hclsyntax",
|
||||
"httpapi",
|
||||
"httpmw",
|
||||
"idtoken",
|
||||
"Iflag",
|
||||
"incpatch",
|
||||
"initialisms",
|
||||
"ipnstate",
|
||||
"isatty",
|
||||
"Jobf",
|
||||
"Keygen",
|
||||
"kirsle",
|
||||
"Kubernetes",
|
||||
"ldflags",
|
||||
"magicsock",
|
||||
"manifoldco",
|
||||
"mapstructure",
|
||||
"mattn",
|
||||
"mitchellh",
|
||||
"moby",
|
||||
"namesgenerator",
|
||||
"namespacing",
|
||||
"netaddr",
|
||||
"netip",
|
||||
"netmap",
|
||||
"netns",
|
||||
"netstack",
|
||||
"nettype",
|
||||
"nfpms",
|
||||
"nhooyr",
|
||||
"nmcfg",
|
||||
"nolint",
|
||||
"nosec",
|
||||
"ntqry",
|
||||
"OIDC",
|
||||
"oneof",
|
||||
"opty",
|
||||
"paralleltest",
|
||||
"parameterscopeid",
|
||||
"pqtype",
|
||||
"prometheusmetrics",
|
||||
"promhttp",
|
||||
"protobuf",
|
||||
"provisionerd",
|
||||
"provisionerdserver",
|
||||
"provisionersdk",
|
||||
"ptty",
|
||||
"ptys",
|
||||
"ptytest",
|
||||
"quickstart",
|
||||
"reconfig",
|
||||
"replicasync",
|
||||
"retrier",
|
||||
"rpty",
|
||||
"SCIM",
|
||||
"sdkproto",
|
||||
"sdktrace",
|
||||
"Signup",
|
||||
"slogtest",
|
||||
"sourcemapped",
|
||||
"spinbutton",
|
||||
"Srcs",
|
||||
"stdbuf",
|
||||
"stretchr",
|
||||
"STTY",
|
||||
"stuntest",
|
||||
"subpage",
|
||||
"tailbroker",
|
||||
"tailcfg",
|
||||
"tailexchange",
|
||||
"tailnet",
|
||||
"tailnettest",
|
||||
"Tailscale",
|
||||
"tanstack",
|
||||
"tbody",
|
||||
"TCGETS",
|
||||
"tcpip",
|
||||
"TCSETS",
|
||||
"templateversions",
|
||||
"testdata",
|
||||
"testid",
|
||||
"testutil",
|
||||
"tfexec",
|
||||
"tfjson",
|
||||
"tfplan",
|
||||
"tfstate",
|
||||
"thead",
|
||||
"tios",
|
||||
"tmpdir",
|
||||
"tokenconfig",
|
||||
"Topbar",
|
||||
"tparallel",
|
||||
"trialer",
|
||||
"trimprefix",
|
||||
"tsdial",
|
||||
"tslogger",
|
||||
"tstun",
|
||||
"turnconn",
|
||||
"typegen",
|
||||
"typesafe",
|
||||
"unconvert",
|
||||
"Untar",
|
||||
"Userspace",
|
||||
"VMID",
|
||||
"walkthrough",
|
||||
"weblinks",
|
||||
"webrtc",
|
||||
"wgcfg",
|
||||
"wgconfig",
|
||||
"wgengine",
|
||||
"wgmonitor",
|
||||
"wgnet",
|
||||
"workspaceagent",
|
||||
"workspaceagents",
|
||||
"workspaceapp",
|
||||
"workspaceapps",
|
||||
"workspacebuilds",
|
||||
"workspacename",
|
||||
"wsjson",
|
||||
"xerrors",
|
||||
"xlarge",
|
||||
"xsmall",
|
||||
"yamux"
|
||||
],
|
||||
"cSpell.ignorePaths": ["site/package.json", ".vscode/settings.json"],
|
||||
"emeraldwalk.runonsave": {
|
||||
"commands": [
|
||||
{
|
||||
"match": "database/queries/*.sql",
|
||||
"cmd": "make gen"
|
||||
},
|
||||
{
|
||||
"match": "provisionerd/proto/provisionerd.proto",
|
||||
"cmd": "make provisionerd/proto/provisionerd.pb.go"
|
||||
}
|
||||
]
|
||||
},
|
||||
"search.exclude": {
|
||||
"**.pb.go": true,
|
||||
"**/*.gen.json": true,
|
||||
"**/testdata/*": true,
|
||||
"coderd/apidoc/**": true,
|
||||
"docs/reference/api/*.md": true,
|
||||
"docs/reference/cli/*.md": true,
|
||||
"docs/templates/*.md": true,
|
||||
"LICENSE": true,
|
||||
"scripts/metricsdocgen/metrics": true,
|
||||
"site/out/**": true,
|
||||
"site/storybook-static/**": true,
|
||||
"**.map": true,
|
||||
"pnpm-lock.yaml": true
|
||||
},
|
||||
// Ensure files always have a newline.
|
||||
"files.insertFinalNewline": true,
|
||||
"go.lintTool": "golangci-lint",
|
||||
"go.lintFlags": ["--fast"],
|
||||
"go.coverageDecorator": {
|
||||
"type": "gutter",
|
||||
"coveredGutterStyle": "blockgreen",
|
||||
"uncoveredGutterStyle": "blockred"
|
||||
},
|
||||
// The codersdk is used by coderd another other packages extensively.
|
||||
// To reduce redundancy in tests, it's covered by other packages.
|
||||
// Since package coverage pairing can't be defined, all packages cover
|
||||
// all other packages.
|
||||
"go.testFlags": ["-short", "-coverpkg=./..."],
|
||||
// We often use a version of TypeScript that's ahead of the version shipped
|
||||
// with VS Code.
|
||||
"typescript.tsdk": "./site/node_modules/typescript/lib",
|
||||
// Playwright tests in VSCode will open a browser to live "view" the test.
|
||||
"playwright.reuseBrowser": true,
|
||||
|
||||
"[javascript][javascriptreact][json][jsonc][typescript][typescriptreact]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
// "editor.codeActionsOnSave": {
|
||||
// "source.organizeImports.biome": "explicit"
|
||||
// }
|
||||
},
|
||||
|
||||
"[css][html][markdown][yaml]": {
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ GOOS := $(shell go env GOOS)
|
||||
GOARCH := $(shell go env GOARCH)
|
||||
GOOS_BIN_EXT := $(if $(filter windows, $(GOOS)),.exe,)
|
||||
VERSION := $(shell ./scripts/version.sh)
|
||||
POSTGRES_VERSION ?= 16
|
||||
|
||||
# Use the highest ZSTD compression level in CI.
|
||||
ifdef CI
|
||||
@@ -390,25 +391,30 @@ BOLD := $(shell tput bold 2>/dev/null)
|
||||
GREEN := $(shell tput setaf 2 2>/dev/null)
|
||||
RESET := $(shell tput sgr0 2>/dev/null)
|
||||
|
||||
fmt: fmt/eslint fmt/prettier fmt/terraform fmt/shfmt fmt/go
|
||||
fmt: fmt/ts fmt/go fmt/terraform fmt/shfmt fmt/prettier
|
||||
.PHONY: fmt
|
||||
|
||||
fmt/go:
|
||||
go mod tidy
|
||||
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/go$(RESET)"
|
||||
# VS Code users should check out
|
||||
# https://github.com/mvdan/gofumpt#visual-studio-code
|
||||
go run mvdan.cc/gofumpt@v0.4.0 -w -l .
|
||||
.PHONY: fmt/go
|
||||
|
||||
fmt/eslint:
|
||||
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/eslint$(RESET)"
|
||||
fmt/ts:
|
||||
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/ts$(RESET)"
|
||||
cd site
|
||||
pnpm run lint:fix
|
||||
.PHONY: fmt/eslint
|
||||
# Avoid writing files in CI to reduce file write activity
|
||||
ifdef CI
|
||||
pnpm run check --linter-enabled=false
|
||||
else
|
||||
pnpm run check:fix
|
||||
endif
|
||||
.PHONY: fmt/ts
|
||||
|
||||
fmt/prettier:
|
||||
fmt/prettier: .prettierignore
|
||||
echo "$(GREEN)==>$(RESET) $(BOLD)fmt/prettier$(RESET)"
|
||||
cd site
|
||||
# Avoid writing files in CI to reduce file write activity
|
||||
ifdef CI
|
||||
pnpm run format:check
|
||||
@@ -441,14 +447,14 @@ lint/site-icons:
|
||||
|
||||
lint/ts:
|
||||
cd site
|
||||
pnpm i && pnpm lint
|
||||
pnpm lint
|
||||
.PHONY: lint/ts
|
||||
|
||||
lint/go:
|
||||
./scripts/check_enterprise_imports.sh
|
||||
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/Dockerfile | cut -d '=' -f 2)
|
||||
go install github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver
|
||||
golangci-lint run
|
||||
./scripts/check_codersdk_imports.sh
|
||||
linter_ver=$(shell egrep -o 'GOLANGCI_LINT_VERSION=\S+' dogfood/contents/Dockerfile | cut -d '=' -f 2)
|
||||
go run github.com/golangci/golangci-lint/cmd/golangci-lint@v$$linter_ver run
|
||||
.PHONY: lint/go
|
||||
|
||||
lint/examples:
|
||||
@@ -487,16 +493,14 @@ gen: \
|
||||
site/src/api/typesGenerated.ts \
|
||||
coderd/rbac/object_gen.go \
|
||||
codersdk/rbacresources_gen.go \
|
||||
site/src/api/rbacresourcesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
docs/cli.md \
|
||||
docs/reference/cli/README.md \
|
||||
docs/admin/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
provisioner/terraform/testdata/version \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
site/src/theme/icons.json \
|
||||
examples/examples.gen.json \
|
||||
@@ -517,15 +521,14 @@ gen/mark-fresh:
|
||||
$(DB_GEN_FILES) \
|
||||
site/src/api/typesGenerated.ts \
|
||||
coderd/rbac/object_gen.go \
|
||||
codersdk/rbacresources_gen.go \
|
||||
site/src/api/rbacresourcesGenerated.ts \
|
||||
docs/admin/prometheus.md \
|
||||
docs/cli.md \
|
||||
docs/reference/cli/README.md \
|
||||
docs/admin/audit-logs.md \
|
||||
coderd/apidoc/swagger.json \
|
||||
.prettierignore.include \
|
||||
.prettierignore \
|
||||
site/.prettierrc.yaml \
|
||||
site/.prettierignore \
|
||||
site/.eslintignore \
|
||||
site/e2e/provisionerGenerated.ts \
|
||||
site/src/theme/icons.json \
|
||||
examples/examples.gen.json \
|
||||
@@ -600,7 +603,6 @@ provisionerd/proto/provisionerd.pb.go: provisionerd/proto/provisionerd.proto
|
||||
site/src/api/typesGenerated.ts: $(wildcard scripts/apitypings/*) $(shell find ./codersdk $(FIND_EXCLUSIONS) -type f -name '*.go')
|
||||
go run ./scripts/apitypings/ > $@
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write "$@"
|
||||
|
||||
site/e2e/provisionerGenerated.ts: provisionerd/proto/provisionerd.pb.go provisionersdk/proto/provisioner.pb.go
|
||||
cd site
|
||||
@@ -610,26 +612,30 @@ site/e2e/provisionerGenerated.ts: provisionerd/proto/provisionerd.pb.go provisio
|
||||
site/src/theme/icons.json: $(wildcard scripts/gensite/*) $(wildcard site/static/icon/*)
|
||||
go run ./scripts/gensite/ -icons "$@"
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write "$@"
|
||||
pnpm -C site/ exec biome format --write src/theme/icons.json
|
||||
|
||||
examples/examples.gen.json: scripts/examplegen/main.go examples/examples.go $(shell find ./examples/templates)
|
||||
go run ./scripts/examplegen/main.go > examples/examples.gen.json
|
||||
|
||||
coderd/rbac/object_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
|
||||
coderd/rbac/object_gen.go: scripts/rbacgen/rbacobject.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
go run scripts/rbacgen/main.go rbac > coderd/rbac/object_gen.go
|
||||
|
||||
codersdk/rbacresources_gen.go: scripts/rbacgen/main.go coderd/rbac/object.go
|
||||
codersdk/rbacresources_gen.go: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
go run scripts/rbacgen/main.go codersdk > codersdk/rbacresources_gen.go
|
||||
|
||||
site/src/api/rbacresourcesGenerated.ts: scripts/rbacgen/codersdk.gotmpl scripts/rbacgen/main.go coderd/rbac/object.go coderd/rbac/policy/policy.go
|
||||
go run scripts/rbacgen/main.go typescript > "$@"
|
||||
|
||||
|
||||
docs/admin/prometheus.md: scripts/metricsdocgen/main.go scripts/metricsdocgen/metrics
|
||||
go run scripts/metricsdocgen/main.go
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write ./docs/admin/prometheus.md
|
||||
|
||||
docs/cli.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
docs/reference/cli/README.md: scripts/clidocgen/main.go examples/examples.gen.json $(GO_SRC_FILES)
|
||||
CI=true BASE_PATH="." go run ./scripts/clidocgen
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write ./docs/cli.md ./docs/cli/*.md ./docs/manifest.json
|
||||
pnpm exec prettier --write ./docs/reference/cli/README.md ./docs/reference/cli/*.md ./docs/manifest.json
|
||||
|
||||
docs/admin/audit-logs.md: coderd/database/querier.go scripts/auditdocgen/main.go enterprise/audit/table.go coderd/rbac/object_gen.go
|
||||
go run scripts/auditdocgen/main.go
|
||||
@@ -639,7 +645,7 @@ docs/admin/audit-logs.md: coderd/database/querier.go scripts/auditdocgen/main.go
|
||||
coderd/apidoc/swagger.json: $(shell find ./scripts/apidocgen $(FIND_EXCLUSIONS) -type f) $(wildcard coderd/*.go) $(wildcard enterprise/coderd/*.go) $(wildcard codersdk/*.go) $(wildcard enterprise/wsproxy/wsproxysdk/*.go) $(DB_GEN_FILES) .swaggo docs/manifest.json coderd/rbac/object_gen.go
|
||||
./scripts/apidocgen/generate.sh
|
||||
./scripts/pnpm_install.sh
|
||||
pnpm exec prettier --write ./docs/api ./docs/manifest.json ./coderd/apidoc/swagger.json
|
||||
pnpm exec prettier --write ./docs/reference/api ./docs/manifest.json ./coderd/apidoc/swagger.json
|
||||
|
||||
update-golden-files: \
|
||||
cli/testdata/.gen-golden \
|
||||
@@ -695,23 +701,6 @@ scripts/ci-report/testdata/.gen-golden: $(wildcard scripts/ci-report/testdata/*)
|
||||
go test ./scripts/ci-report -run=TestOutputMatchesGoldenFile -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/"; "./") | sub("../!"; "!../"))' \
|
||||
"$<" >> "$@"
|
||||
|
||||
# Combine .gitignore with .prettierignore.include to generate .prettierignore.
|
||||
.prettierignore: .gitignore .prettierignore.include
|
||||
echo "# Code generated by Makefile ($^). DO NOT EDIT." > "$@"
|
||||
@@ -721,40 +710,6 @@ site/.prettierrc.yaml: .prettierrc.yaml
|
||||
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:
|
||||
$(GIT_FLAGS) gotestsum --format standard-quiet -- -v -short -count=1 ./...
|
||||
.PHONY: test
|
||||
@@ -814,7 +769,7 @@ test-migrations: test-postgres-docker
|
||||
|
||||
# NOTE: we set --memory to the same size as a GitHub runner.
|
||||
test-postgres-docker:
|
||||
docker rm -f test-postgres-docker || true
|
||||
docker rm -f test-postgres-docker-${POSTGRES_VERSION} || true
|
||||
docker run \
|
||||
--env POSTGRES_PASSWORD=postgres \
|
||||
--env POSTGRES_USER=postgres \
|
||||
@@ -822,11 +777,11 @@ test-postgres-docker:
|
||||
--env PGDATA=/tmp \
|
||||
--tmpfs /tmp \
|
||||
--publish 5432:5432 \
|
||||
--name test-postgres-docker \
|
||||
--name test-postgres-docker-${POSTGRES_VERSION} \
|
||||
--restart no \
|
||||
--detach \
|
||||
--memory 16GB \
|
||||
gcr.io/coder-dev-1/postgres:13 \
|
||||
gcr.io/coder-dev-1/postgres:${POSTGRES_VERSION} \
|
||||
-c shared_buffers=1GB \
|
||||
-c work_mem=1GB \
|
||||
-c effective_cache_size=1GB \
|
||||
@@ -865,3 +820,7 @@ test-tailnet-integration:
|
||||
test-clean:
|
||||
go clean -testcache
|
||||
.PHONY: test-clean
|
||||
|
||||
.PHONY: test-e2e
|
||||
test-e2e:
|
||||
cd ./site && DEBUG=pw:api pnpm playwright:test --forbid-only --workers 1
|
||||
|
||||
@@ -20,17 +20,17 @@
|
||||
<br>
|
||||
<br>
|
||||
|
||||
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/v2/latest/enterprise)
|
||||
[Quickstart](#quickstart) | [Docs](https://coder.com/docs) | [Why Coder](https://coder.com/why) | [Enterprise](https://coder.com/docs/enterprise)
|
||||
|
||||
[](https://discord.gg/coder)
|
||||
[](https://github.com/coder/coder/releases/latest)
|
||||
[](https://pkg.go.dev/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder)
|
||||
[](https://goreportcard.com/report/github.com/coder/coder/v2)
|
||||
[](./LICENSE)
|
||||
|
||||
</div>
|
||||
|
||||
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and are automatically shut down when not in use to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads that are most beneficial to them.
|
||||
[Coder](https://coder.com) enables organizations to set up development environments in their public or private cloud infrastructure. Cloud development environments are defined with Terraform, connected through a secure high-speed Wireguard® tunnel, and automatically shut down when not used to save on costs. Coder gives engineering teams the flexibility to use the cloud for workloads most beneficial to them.
|
||||
|
||||
- Define cloud development environments in Terraform
|
||||
- EC2 VMs, Kubernetes Pods, Docker Containers, etc.
|
||||
@@ -53,7 +53,7 @@ curl -L https://coder.com/install.sh | sh
|
||||
coder server
|
||||
|
||||
# Navigate to http://localhost:3000 to create your initial user,
|
||||
# create a Docker template, and provision a workspace
|
||||
# create a Docker template and provision a workspace
|
||||
```
|
||||
|
||||
## Install
|
||||
@@ -69,7 +69,7 @@ curl -L https://coder.com/install.sh | sh
|
||||
|
||||
You can run the install script with `--dry-run` to see the commands that will be used to install without executing them. Run the install script with `--help` for additional flags.
|
||||
|
||||
> See [install](https://coder.com/docs/v2/latest/install) for additional methods.
|
||||
> See [install](https://coder.com/docs/install) for additional methods.
|
||||
|
||||
Once installed, you can start a production deployment with a single command:
|
||||
|
||||
@@ -81,27 +81,27 @@ coder server
|
||||
coder server --postgres-url <url> --access-url <url>
|
||||
```
|
||||
|
||||
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/v2/latest/install) for a full walkthrough.
|
||||
Use `coder --help` to get a list of flags and environment variables. Use our [install guides](https://coder.com/docs/install) for a complete walkthrough.
|
||||
|
||||
## Documentation
|
||||
|
||||
Browse our docs [here](https://coder.com/docs/v2) or visit a specific section below:
|
||||
Browse our docs [here](https://coder.com/docs) or visit a specific section below:
|
||||
|
||||
- [**Templates**](https://coder.com/docs/v2/latest/templates): Templates are written in Terraform and describe the infrastructure for workspaces
|
||||
- [**Workspaces**](https://coder.com/docs/v2/latest/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
|
||||
- [**IDEs**](https://coder.com/docs/v2/latest/ides): Connect your existing editor to a workspace
|
||||
- [**Administration**](https://coder.com/docs/v2/latest/admin): Learn how to operate Coder
|
||||
- [**Enterprise**](https://coder.com/docs/v2/latest/enterprise): Learn about our paid features built for large teams
|
||||
- [**Templates**](https://coder.com/docs/templates): Templates are written in Terraform and describe the infrastructure for workspaces
|
||||
- [**Workspaces**](https://coder.com/docs/workspaces): Workspaces contain the IDEs, dependencies, and configuration information needed for software development
|
||||
- [**IDEs**](https://coder.com/docs/ides): Connect your existing editor to a workspace
|
||||
- [**Administration**](https://coder.com/docs/admin): Learn how to operate Coder
|
||||
- [**Enterprise**](https://coder.com/docs/enterprise): Learn about our paid features built for large teams
|
||||
|
||||
## Support
|
||||
|
||||
Feel free to [open an issue](https://github.com/coder/coder/issues/new) if you have questions, run into bugs, or have a feature request.
|
||||
|
||||
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features, and chat with the community using Coder!
|
||||
[Join our Discord](https://discord.gg/coder) to provide feedback on in-progress features and chat with the community using Coder!
|
||||
|
||||
## Integrations
|
||||
|
||||
We are always working on new integrations. Feel free to open an issue to request an integration. Contributions are welcome in any official or community repositories.
|
||||
We are always working on new integrations. Please feel free to open an issue and ask for an integration. Contributions are welcome in any official or community repositories.
|
||||
|
||||
### Official
|
||||
|
||||
@@ -120,9 +120,9 @@ We are always working on new integrations. Feel free to open an issue to request
|
||||
## Contributing
|
||||
|
||||
We are always happy to see new contributors to Coder. If you are new to the Coder codebase, we have
|
||||
[a guide on how to get started](https://coder.com/docs/v2/latest/CONTRIBUTING). We'd love to see your
|
||||
[a guide on how to get started](https://coder.com/docs/CONTRIBUTING). We'd love to see your
|
||||
contributions!
|
||||
|
||||
## Hiring
|
||||
|
||||
Apply [here](https://cdr.co/github-apply) if you're interested in joining our team.
|
||||
Apply [here](https://jobs.ashbyhq.com/coder?utm_source=github&utm_medium=readme&utm_campaign=unknown) if you're interested in joining our team.
|
||||
|
||||
+37
-18
@@ -91,6 +91,7 @@ type Options struct {
|
||||
ModifiedProcesses chan []*agentproc.Process
|
||||
// ProcessManagementTick is used for testing process priority management.
|
||||
ProcessManagementTick <-chan time.Time
|
||||
BlockFileTransfer bool
|
||||
}
|
||||
|
||||
type Client interface {
|
||||
@@ -184,6 +185,7 @@ func New(options Options) Agent {
|
||||
modifiedProcs: options.ModifiedProcesses,
|
||||
processManagementTick: options.ProcessManagementTick,
|
||||
logSender: agentsdk.NewLogSender(options.Logger),
|
||||
blockFileTransfer: options.BlockFileTransfer,
|
||||
|
||||
prometheusRegistry: prometheusRegistry,
|
||||
metrics: newAgentMetrics(prometheusRegistry),
|
||||
@@ -239,6 +241,7 @@ type agent struct {
|
||||
sessionToken atomic.Pointer[string]
|
||||
sshServer *agentssh.Server
|
||||
sshMaxTimeout time.Duration
|
||||
blockFileTransfer bool
|
||||
|
||||
lifecycleUpdate chan struct{}
|
||||
lifecycleReported chan codersdk.WorkspaceAgentLifecycle
|
||||
@@ -277,6 +280,7 @@ func (a *agent) init() {
|
||||
AnnouncementBanners: func() *[]codersdk.BannerConfig { return a.announcementBanners.Load() },
|
||||
UpdateEnv: a.updateCommandEnv,
|
||||
WorkingDirectory: func() string { return a.manifest.Load().Directory },
|
||||
BlockFileTransfer: a.blockFileTransfer,
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -584,10 +588,12 @@ func (a *agent) reportMetadata(ctx context.Context, conn drpc.Conn) error {
|
||||
updatedMetadata[mr.key] = mr.result
|
||||
continue
|
||||
case err := <-reportError:
|
||||
a.logger.Debug(ctx, "batch update metadata complete", slog.Error(err))
|
||||
logMsg := "batch update metadata complete"
|
||||
if err != nil {
|
||||
a.logger.Debug(ctx, logMsg, slog.Error(err))
|
||||
return xerrors.Errorf("failed to report metadata: %w", err)
|
||||
}
|
||||
a.logger.Debug(ctx, logMsg)
|
||||
reportInFlight = false
|
||||
case <-report:
|
||||
if len(updatedMetadata) == 0 {
|
||||
@@ -935,7 +941,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
}
|
||||
}
|
||||
|
||||
err = a.scriptRunner.Init(manifest.Scripts)
|
||||
err = a.scriptRunner.Init(manifest.Scripts, aAPI.ScriptCompleted)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("init script runner: %w", err)
|
||||
}
|
||||
@@ -943,9 +949,7 @@ func (a *agent) handleManifest(manifestOK *checkpoint) func(ctx context.Context,
|
||||
start := time.Now()
|
||||
// here we use the graceful context because the script runner is not directly tied
|
||||
// to the agent API.
|
||||
err := a.scriptRunner.Execute(a.gracefulCtx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return script.RunOnStart
|
||||
})
|
||||
err := a.scriptRunner.Execute(a.gracefulCtx, agentscripts.ExecuteStartScripts)
|
||||
// Measure the time immediately after the script has finished
|
||||
dur := time.Since(start).Seconds()
|
||||
if err != nil {
|
||||
@@ -1113,9 +1117,6 @@ func (a *agent) wireguardAddresses(agentID uuid.UUID) []netip.Prefix {
|
||||
return []netip.Prefix{
|
||||
// This is the IP that should be used primarily.
|
||||
netip.PrefixFrom(tailnet.IPFromUUID(agentID), 128),
|
||||
// We also listen on the legacy codersdk.WorkspaceAgentIP. This
|
||||
// allows for a transition away from wsconncache.
|
||||
netip.PrefixFrom(workspacesdk.AgentIP, 128),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1354,7 +1355,7 @@ func (a *agent) runCoordinator(ctx context.Context, conn drpc.Conn, network *tai
|
||||
defer close(errCh)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
err := coordination.Close()
|
||||
err := coordination.Close(a.hardCtx)
|
||||
if err != nil {
|
||||
a.logger.Warn(ctx, "failed to close remote coordination", slog.Error(err))
|
||||
}
|
||||
@@ -1504,6 +1505,8 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
var mu sync.Mutex
|
||||
status := a.network.Status()
|
||||
durations := []float64{}
|
||||
p2pConns := 0
|
||||
derpConns := 0
|
||||
pingCtx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelFunc()
|
||||
for nodeID, peer := range status.Peer {
|
||||
@@ -1520,13 +1523,18 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
duration, _, _, err := a.network.Ping(pingCtx, addresses[0].Addr())
|
||||
duration, p2p, _, err := a.network.Ping(pingCtx, addresses[0].Addr())
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
durations = append(durations, float64(duration.Microseconds()))
|
||||
if p2p {
|
||||
p2pConns++
|
||||
} else {
|
||||
derpConns++
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
@@ -1546,6 +1554,9 @@ func (a *agent) Collect(ctx context.Context, networkStats map[netlogtype.Connect
|
||||
// Agent metrics are changing all the time, so there is no need to perform
|
||||
// reflect.DeepEqual to see if stats should be transferred.
|
||||
|
||||
// currentConnections behaves like a hypothetical `GaugeFuncVec` and is only set at collection time.
|
||||
a.metrics.currentConnections.WithLabelValues("p2p").Set(float64(p2pConns))
|
||||
a.metrics.currentConnections.WithLabelValues("derp").Set(float64(derpConns))
|
||||
metricsCtx, cancelFunc := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancelFunc()
|
||||
a.logger.Debug(ctx, "collecting agent metrics for stats")
|
||||
@@ -1663,13 +1674,12 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
}
|
||||
|
||||
score, niceErr := proc.Niceness(a.syscaller)
|
||||
if niceErr != nil && !xerrors.Is(niceErr, os.ErrPermission) {
|
||||
if niceErr != nil && !isBenignProcessErr(niceErr) {
|
||||
debouncer.Warn(ctx, "unable to get proc niceness",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
slog.Error(niceErr),
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
// We only want processes that don't have a nice value set
|
||||
@@ -1683,7 +1693,7 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
|
||||
if niceErr == nil {
|
||||
err := proc.SetNiceness(a.syscaller, niceness)
|
||||
if err != nil && !xerrors.Is(err, os.ErrPermission) {
|
||||
if err != nil && !isBenignProcessErr(err) {
|
||||
debouncer.Warn(ctx, "unable to set proc niceness",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
@@ -1697,7 +1707,7 @@ func (a *agent) manageProcessPriority(ctx context.Context, debouncer *logDebounc
|
||||
if oomScore != unsetOOMScore && oomScore != proc.OOMScoreAdj && !isCustomOOMScore(agentScore, proc) {
|
||||
oomScoreStr := strconv.Itoa(oomScore)
|
||||
err := afero.WriteFile(a.filesystem, fmt.Sprintf("/proc/%d/oom_score_adj", proc.PID), []byte(oomScoreStr), 0o644)
|
||||
if err != nil && !xerrors.Is(err, os.ErrPermission) {
|
||||
if err != nil && !isBenignProcessErr(err) {
|
||||
debouncer.Warn(ctx, "unable to set oom_score_adj",
|
||||
slog.F("cmd", proc.Cmd()),
|
||||
slog.F("pid", proc.PID),
|
||||
@@ -1781,7 +1791,7 @@ func (a *agent) HandleHTTPDebugLogs(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// Limit to 10MB.
|
||||
// Limit to 10MiB.
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, err = io.Copy(w, io.LimitReader(f, 10*1024*1024))
|
||||
if err != nil && !errors.Is(err, io.EOF) {
|
||||
@@ -1832,9 +1842,7 @@ func (a *agent) Close() error {
|
||||
a.gracefulCancel()
|
||||
|
||||
lifecycleState := codersdk.WorkspaceAgentLifecycleOff
|
||||
err = a.scriptRunner.Execute(a.hardCtx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return script.RunOnStop
|
||||
})
|
||||
err = a.scriptRunner.Execute(a.hardCtx, agentscripts.ExecuteStopScripts)
|
||||
if err != nil {
|
||||
a.logger.Warn(a.hardCtx, "shutdown script(s) failed", slog.Error(err))
|
||||
if errors.Is(err, agentscripts.ErrTimeout) {
|
||||
@@ -2133,3 +2141,14 @@ func (l *logDebouncer) log(ctx context.Context, level slog.Level, msg string, fi
|
||||
}
|
||||
l.messages[msg] = time.Now()
|
||||
}
|
||||
|
||||
func isBenignProcessErr(err error) bool {
|
||||
return err != nil &&
|
||||
(xerrors.Is(err, os.ErrNotExist) ||
|
||||
xerrors.Is(err, os.ErrPermission) ||
|
||||
isNoSuchProcessErr(err))
|
||||
}
|
||||
|
||||
func isNoSuchProcessErr(err error) bool {
|
||||
return err != nil && strings.Contains(err.Error(), "no such process")
|
||||
}
|
||||
|
||||
+153
-44
@@ -970,6 +970,99 @@ func TestAgent_SCP(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestAgent_FileTransferBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertFileTransferBlocked := func(t *testing.T, errorMessage string) {
|
||||
// NOTE: Checking content of the error message is flaky. Most likely there is a race condition, which results
|
||||
// in stopping the client in different phases, and returning different errors:
|
||||
// - client read the full error message: File transfer has been disabled.
|
||||
// - client's stream was terminated before reading the error message: EOF
|
||||
// - client just read the error code (Windows): Process exited with status 65
|
||||
isErr := strings.Contains(errorMessage, agentssh.BlockedFileTransferErrorMessage) ||
|
||||
strings.Contains(errorMessage, "EOF") ||
|
||||
strings.Contains(errorMessage, "Process exited with status 65")
|
||||
require.True(t, isErr, fmt.Sprintf("Message: "+errorMessage))
|
||||
}
|
||||
|
||||
t.Run("SFTP", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
_, err = sftp.NewClient(sshClient)
|
||||
require.Error(t, err)
|
||||
assertFileTransferBlocked(t, err.Error())
|
||||
})
|
||||
|
||||
t.Run("SCP with go-scp package", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
scpClient, err := scp.NewClientBySSH(sshClient)
|
||||
require.NoError(t, err)
|
||||
defer scpClient.Close()
|
||||
tempFile := filepath.Join(t.TempDir(), "scp")
|
||||
err = scpClient.CopyFile(context.Background(), strings.NewReader("hello world"), tempFile, "0755")
|
||||
require.Error(t, err)
|
||||
assertFileTransferBlocked(t, err.Error())
|
||||
})
|
||||
|
||||
t.Run("Forbidden commands", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, c := range agentssh.BlockedFileTransferCommands {
|
||||
t.Run(c, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
//nolint:dogsled
|
||||
conn, _, _, _, _ := setupAgent(t, agentsdk.Manifest{}, 0, func(_ *agenttest.Client, o *agent.Options) {
|
||||
o.BlockFileTransfer = true
|
||||
})
|
||||
sshClient, err := conn.SSHClient(ctx)
|
||||
require.NoError(t, err)
|
||||
defer sshClient.Close()
|
||||
|
||||
session, err := sshClient.NewSession()
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
stdout, err := session.StdoutPipe()
|
||||
require.NoError(t, err)
|
||||
|
||||
//nolint:govet // we don't need `c := c` in Go 1.22
|
||||
err = session.Start(c)
|
||||
require.NoError(t, err)
|
||||
defer session.Close()
|
||||
|
||||
msg, err := io.ReadAll(stdout)
|
||||
require.NoError(t, err)
|
||||
assertFileTransferBlocked(t, string(msg))
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestAgent_EnvironmentVariables(t *testing.T) {
|
||||
t.Parallel()
|
||||
key := "EXAMPLE"
|
||||
@@ -1424,10 +1517,12 @@ func TestAgent_Lifecycle(t *testing.T) {
|
||||
agentsdk.Manifest{
|
||||
DERPMap: derpMap,
|
||||
Scripts: []codersdk.WorkspaceAgentScript{{
|
||||
ID: uuid.New(),
|
||||
LogPath: "coder-startup-script.log",
|
||||
Script: "echo 1",
|
||||
RunOnStart: true,
|
||||
}, {
|
||||
ID: uuid.New(),
|
||||
LogPath: "coder-shutdown-script.log",
|
||||
Script: "echo " + expected,
|
||||
RunOnStop: true,
|
||||
@@ -1803,7 +1898,9 @@ func TestAgent_UpdatedDERP(t *testing.T) {
|
||||
coordinator, conn)
|
||||
t.Cleanup(func() {
|
||||
t.Logf("closing coordination %s", name)
|
||||
err := coordination.Close()
|
||||
cctx, ccancel := context.WithTimeout(testCtx, testutil.WaitShort)
|
||||
defer ccancel()
|
||||
err := coordination.Close(cctx)
|
||||
if err != nil {
|
||||
t.Logf("error closing in-memory coordination: %s", err.Error())
|
||||
}
|
||||
@@ -2291,7 +2388,9 @@ func setupAgent(t *testing.T, metadata agentsdk.Manifest, ptyTimeout time.Durati
|
||||
clientID, metadata.AgentID,
|
||||
coordinator, conn)
|
||||
t.Cleanup(func() {
|
||||
err := coordination.Close()
|
||||
cctx, ccancel := context.WithTimeout(testCtx, testutil.WaitShort)
|
||||
defer ccancel()
|
||||
err := coordination.Close(cctx)
|
||||
if err != nil {
|
||||
t.Logf("error closing in-mem coordination: %s", err.Error())
|
||||
}
|
||||
@@ -2438,17 +2537,17 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
err = session.Shell()
|
||||
require.NoError(t, err)
|
||||
|
||||
expected := []agentsdk.AgentMetric{
|
||||
expected := []*proto.Stats_Metric{
|
||||
{
|
||||
Name: "agent_reconnecting_pty_connections_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_sessions_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 1,
|
||||
Labels: []agentsdk.AgentMetricLabel{
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "magic_type",
|
||||
Value: "ssh",
|
||||
@@ -2461,30 +2560,46 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_failed_connections_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_connections_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "agent_ssh_server_sftp_server_errors_total",
|
||||
Type: agentsdk.AgentMetricTypeCounter,
|
||||
Type: proto.Stats_Metric_COUNTER,
|
||||
Value: 0,
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: agentsdk.AgentMetricTypeGauge,
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 0,
|
||||
Labels: []agentsdk.AgentMetricLabel{
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "success",
|
||||
Value: "true",
|
||||
Name: "connection_type",
|
||||
Value: "derp",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_currently_reachable_peers",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
Labels: []*proto.Stats_Metric_Label{
|
||||
{
|
||||
Name: "connection_type",
|
||||
Value: "p2p",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "coderd_agentstats_startup_script_seconds",
|
||||
Type: proto.Stats_Metric_GAUGE,
|
||||
Value: 1,
|
||||
},
|
||||
}
|
||||
|
||||
var actual []*promgo.MetricFamily
|
||||
@@ -2493,17 +2608,33 @@ func TestAgent_Metrics_SSH(t *testing.T) {
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
if len(expected) != len(actual) {
|
||||
return false
|
||||
count := 0
|
||||
for _, m := range actual {
|
||||
count += len(m.GetMetric())
|
||||
}
|
||||
|
||||
return verifyCollectedMetrics(t, expected, actual)
|
||||
return count == len(expected)
|
||||
}, testutil.WaitLong, testutil.IntervalFast)
|
||||
|
||||
require.Len(t, actual, len(expected))
|
||||
collected := verifyCollectedMetrics(t, expected, actual)
|
||||
require.True(t, collected, "expected metrics were not collected")
|
||||
i := 0
|
||||
for _, mf := range actual {
|
||||
for _, m := range mf.GetMetric() {
|
||||
assert.Equal(t, expected[i].Name, mf.GetName())
|
||||
assert.Equal(t, expected[i].Type.String(), mf.GetType().String())
|
||||
// Value is max expected
|
||||
if expected[i].Type == proto.Stats_Metric_GAUGE {
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetGauge().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetGauge().GetValue())
|
||||
} else if expected[i].Type == proto.Stats_Metric_COUNTER {
|
||||
assert.GreaterOrEqualf(t, expected[i].Value, m.GetCounter().GetValue(), "expected %s to be greater than or equal to %f, got %f", expected[i].Name, expected[i].Value, m.GetCounter().GetValue())
|
||||
}
|
||||
for j, lbl := range expected[i].Labels {
|
||||
assert.Equal(t, m.GetLabel()[j], &promgo.LabelPair{
|
||||
Name: &lbl.Name,
|
||||
Value: &lbl.Value,
|
||||
})
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
_ = stdin.Close()
|
||||
err = session.Wait()
|
||||
@@ -2735,28 +2866,6 @@ func TestAgent_ManageProcessPriority(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func verifyCollectedMetrics(t *testing.T, expected []agentsdk.AgentMetric, actual []*promgo.MetricFamily) bool {
|
||||
t.Helper()
|
||||
|
||||
for i, e := range expected {
|
||||
assert.Equal(t, e.Name, actual[i].GetName())
|
||||
assert.Equal(t, string(e.Type), strings.ToLower(actual[i].GetType().String()))
|
||||
|
||||
for _, m := range actual[i].GetMetric() {
|
||||
assert.Equal(t, e.Value, m.Counter.GetValue())
|
||||
|
||||
if len(m.GetLabel()) > 0 {
|
||||
for j, lbl := range m.GetLabel() {
|
||||
assert.Equal(t, e.Labels[j].Name, lbl.GetName())
|
||||
assert.Equal(t, e.Labels[j].Value, lbl.GetValue())
|
||||
}
|
||||
}
|
||||
m.GetLabel()
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type syncWriter struct {
|
||||
mu sync.Mutex
|
||||
w io.Writer
|
||||
|
||||
@@ -45,8 +45,7 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
|
||||
cmdline, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "cmdline"))
|
||||
if err != nil {
|
||||
var errNo syscall.Errno
|
||||
if xerrors.As(err, &errNo) && errNo == syscall.EPERM {
|
||||
if isBenignError(err) {
|
||||
continue
|
||||
}
|
||||
return nil, xerrors.Errorf("read cmdline: %w", err)
|
||||
@@ -54,7 +53,7 @@ func List(fs afero.Fs, syscaller Syscaller) ([]*Process, error) {
|
||||
|
||||
oomScore, err := afero.ReadFile(fs, filepath.Join(defaultProcDir, entry, "oom_score_adj"))
|
||||
if err != nil {
|
||||
if xerrors.Is(err, os.ErrPermission) {
|
||||
if isBenignError(err) {
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -124,3 +123,12 @@ func (p *Process) Cmd() string {
|
||||
func (p *Process) cmdLine() []string {
|
||||
return strings.Split(p.CmdLine, "\x00")
|
||||
}
|
||||
|
||||
func isBenignError(err error) bool {
|
||||
var errno syscall.Errno
|
||||
if !xerrors.As(err, &errno) {
|
||||
return false
|
||||
}
|
||||
|
||||
return errno == syscall.ESRCH || errno == syscall.EPERM || xerrors.Is(err, os.ErrNotExist)
|
||||
}
|
||||
|
||||
@@ -19,10 +19,13 @@ import (
|
||||
"github.com/spf13/afero"
|
||||
"golang.org/x/sync/errgroup"
|
||||
"golang.org/x/xerrors"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
|
||||
"cdr.dev/slog"
|
||||
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/proto"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
)
|
||||
@@ -75,18 +78,21 @@ func New(opts Options) *Runner {
|
||||
}
|
||||
}
|
||||
|
||||
type ScriptCompletedFunc func(context.Context, *proto.WorkspaceAgentScriptCompletedRequest) (*proto.WorkspaceAgentScriptCompletedResponse, error)
|
||||
|
||||
type Runner struct {
|
||||
Options
|
||||
|
||||
cronCtx context.Context
|
||||
cronCtxCancel context.CancelFunc
|
||||
cmdCloseWait sync.WaitGroup
|
||||
closed chan struct{}
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
dataDir string
|
||||
cronCtx context.Context
|
||||
cronCtxCancel context.CancelFunc
|
||||
cmdCloseWait sync.WaitGroup
|
||||
closed chan struct{}
|
||||
closeMutex sync.Mutex
|
||||
cron *cron.Cron
|
||||
initialized atomic.Bool
|
||||
scripts []codersdk.WorkspaceAgentScript
|
||||
dataDir string
|
||||
scriptCompleted ScriptCompletedFunc
|
||||
|
||||
// scriptsExecuted includes all scripts executed by the workspace agent. Agents
|
||||
// execute startup scripts, and scripts on a cron schedule. Both will increment
|
||||
@@ -116,12 +122,13 @@ func (r *Runner) RegisterMetrics(reg prometheus.Registerer) {
|
||||
// Init initializes the runner with the provided scripts.
|
||||
// It also schedules any scripts that have a schedule.
|
||||
// This function must be called before Execute.
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error {
|
||||
func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript, scriptCompleted ScriptCompletedFunc) error {
|
||||
if r.initialized.Load() {
|
||||
return xerrors.New("init: already initialized")
|
||||
}
|
||||
r.initialized.Store(true)
|
||||
r.scripts = scripts
|
||||
r.scriptCompleted = scriptCompleted
|
||||
r.Logger.Info(r.cronCtx, "initializing agent scripts", slog.F("script_count", len(scripts)), slog.F("log_dir", r.LogDir))
|
||||
|
||||
err := r.Filesystem.MkdirAll(r.ScriptBinDir(), 0o700)
|
||||
@@ -135,7 +142,7 @@ func (r *Runner) Init(scripts []codersdk.WorkspaceAgentScript) error {
|
||||
}
|
||||
script := script
|
||||
_, err := r.cron.AddFunc(script.Cron, func() {
|
||||
err := r.trackRun(r.cronCtx, script)
|
||||
err := r.trackRun(r.cronCtx, script, ExecuteCronScripts)
|
||||
if err != nil {
|
||||
r.Logger.Warn(context.Background(), "run agent script on schedule", slog.Error(err))
|
||||
}
|
||||
@@ -172,22 +179,33 @@ func (r *Runner) StartCron() {
|
||||
}
|
||||
}
|
||||
|
||||
// ExecuteOption describes what scripts we want to execute.
|
||||
type ExecuteOption int
|
||||
|
||||
// ExecuteOption enums.
|
||||
const (
|
||||
ExecuteAllScripts ExecuteOption = iota
|
||||
ExecuteStartScripts
|
||||
ExecuteStopScripts
|
||||
ExecuteCronScripts
|
||||
)
|
||||
|
||||
// Execute runs a set of scripts according to a filter.
|
||||
func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.WorkspaceAgentScript) bool) error {
|
||||
if filter == nil {
|
||||
// Execute em' all!
|
||||
filter = func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
}
|
||||
}
|
||||
func (r *Runner) Execute(ctx context.Context, option ExecuteOption) error {
|
||||
var eg errgroup.Group
|
||||
for _, script := range r.scripts {
|
||||
if !filter(script) {
|
||||
runScript := (option == ExecuteStartScripts && script.RunOnStart) ||
|
||||
(option == ExecuteStopScripts && script.RunOnStop) ||
|
||||
(option == ExecuteCronScripts && script.Cron != "") ||
|
||||
option == ExecuteAllScripts
|
||||
|
||||
if !runScript {
|
||||
continue
|
||||
}
|
||||
|
||||
script := script
|
||||
eg.Go(func() error {
|
||||
err := r.trackRun(ctx, script)
|
||||
err := r.trackRun(ctx, script, option)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("run agent script %q: %w", script.LogSourceID, err)
|
||||
}
|
||||
@@ -198,8 +216,8 @@ func (r *Runner) Execute(ctx context.Context, filter func(script codersdk.Worksp
|
||||
}
|
||||
|
||||
// trackRun wraps "run" with metrics.
|
||||
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
|
||||
err := r.run(ctx, script)
|
||||
func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
|
||||
err := r.run(ctx, script, option)
|
||||
if err != nil {
|
||||
r.scriptsExecuted.WithLabelValues("false").Add(1)
|
||||
} else {
|
||||
@@ -212,7 +230,7 @@ func (r *Runner) trackRun(ctx context.Context, script codersdk.WorkspaceAgentScr
|
||||
// If the timeout is exceeded, the process is sent an interrupt signal.
|
||||
// If the process does not exit after a few seconds, it is forcefully killed.
|
||||
// This function immediately returns after a timeout, and does not wait for the process to exit.
|
||||
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript) error {
|
||||
func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript, option ExecuteOption) error {
|
||||
logPath := script.LogPath
|
||||
if logPath == "" {
|
||||
logPath = fmt.Sprintf("coder-script-%s.log", script.LogSourceID)
|
||||
@@ -299,9 +317,9 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript)
|
||||
cmd.Stdout = io.MultiWriter(fileWriter, infoW)
|
||||
cmd.Stderr = io.MultiWriter(fileWriter, errW)
|
||||
|
||||
start := time.Now()
|
||||
start := dbtime.Now()
|
||||
defer func() {
|
||||
end := time.Now()
|
||||
end := dbtime.Now()
|
||||
execTime := end.Sub(start)
|
||||
exitCode := 0
|
||||
if err != nil {
|
||||
@@ -314,6 +332,60 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript)
|
||||
} else {
|
||||
logger.Info(ctx, fmt.Sprintf("%s script completed", logPath), slog.F("execution_time", execTime), slog.F("exit_code", exitCode))
|
||||
}
|
||||
|
||||
if r.scriptCompleted == nil {
|
||||
logger.Debug(ctx, "r.scriptCompleted unexpectedly nil")
|
||||
return
|
||||
}
|
||||
|
||||
// We want to check this outside of the goroutine to avoid a race condition
|
||||
timedOut := errors.Is(err, ErrTimeout)
|
||||
pipesLeftOpen := errors.Is(err, ErrOutputPipesOpen)
|
||||
|
||||
err = r.trackCommandGoroutine(func() {
|
||||
var stage proto.Timing_Stage
|
||||
switch option {
|
||||
case ExecuteStartScripts:
|
||||
stage = proto.Timing_START
|
||||
case ExecuteStopScripts:
|
||||
stage = proto.Timing_STOP
|
||||
case ExecuteCronScripts:
|
||||
stage = proto.Timing_CRON
|
||||
}
|
||||
|
||||
var status proto.Timing_Status
|
||||
switch {
|
||||
case timedOut:
|
||||
status = proto.Timing_TIMED_OUT
|
||||
case pipesLeftOpen:
|
||||
status = proto.Timing_PIPES_LEFT_OPEN
|
||||
case exitCode != 0:
|
||||
status = proto.Timing_EXIT_FAILURE
|
||||
default:
|
||||
status = proto.Timing_OK
|
||||
}
|
||||
|
||||
reportTimeout := 30 * time.Second
|
||||
reportCtx, cancel := context.WithTimeout(context.Background(), reportTimeout)
|
||||
defer cancel()
|
||||
|
||||
_, err := r.scriptCompleted(reportCtx, &proto.WorkspaceAgentScriptCompletedRequest{
|
||||
Timing: &proto.Timing{
|
||||
ScriptId: script.ID[:],
|
||||
Start: timestamppb.New(start),
|
||||
End: timestamppb.New(end),
|
||||
ExitCode: int32(exitCode),
|
||||
Stage: stage,
|
||||
Status: status,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, fmt.Sprintf("reporting script completed: %s", err.Error()))
|
||||
}
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, fmt.Sprintf("reporting script completed: track command goroutine: %s", err.Error()))
|
||||
}
|
||||
}()
|
||||
|
||||
err = cmd.Start()
|
||||
@@ -349,7 +421,7 @@ func (r *Runner) run(ctx context.Context, script codersdk.WorkspaceAgentScript)
|
||||
"This usually means a child process was started with references to stdout or stderr. As a result, this " +
|
||||
"process may now have been terminated. Consider redirecting the output or using a separate " +
|
||||
"\"coder_script\" for the process, see " +
|
||||
"https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-issues for more information.",
|
||||
"https://coder.com/docs/templates/troubleshooting#startup-script-issues for more information.",
|
||||
)
|
||||
// Inform the user by propagating the message via log writers.
|
||||
_, _ = fmt.Fprintf(cmd.Stderr, "WARNING: %s. %s\n", message, details)
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/agent/agentscripts"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/agenttest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
@@ -34,14 +35,13 @@ func TestExecuteBasic(t *testing.T) {
|
||||
return fLogger
|
||||
})
|
||||
defer runner.Close()
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo hello",
|
||||
}})
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runner.Execute(context.Background(), func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
}))
|
||||
require.NoError(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts))
|
||||
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
|
||||
require.Equal(t, "hello", log.Output)
|
||||
}
|
||||
@@ -61,18 +61,17 @@ func TestEnv(t *testing.T) {
|
||||
cmd.exe /c echo %CODER_SCRIPT_BIN_DIR%
|
||||
`
|
||||
}
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: id,
|
||||
Script: script,
|
||||
}})
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitLong)
|
||||
|
||||
done := testutil.Go(t, func() {
|
||||
err := runner.Execute(ctx, func(script codersdk.WorkspaceAgentScript) bool {
|
||||
return true
|
||||
})
|
||||
err := runner.Execute(ctx, agentscripts.ExecuteAllScripts)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
defer func() {
|
||||
@@ -103,13 +102,44 @@ func TestTimeout(t *testing.T) {
|
||||
t.Parallel()
|
||||
runner := setup(t, nil)
|
||||
defer runner.Close()
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "sleep infinity",
|
||||
Timeout: time.Millisecond,
|
||||
}})
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.ErrorIs(t, runner.Execute(context.Background(), nil), agentscripts.ErrTimeout)
|
||||
require.ErrorIs(t, runner.Execute(context.Background(), agentscripts.ExecuteAllScripts), agentscripts.ErrTimeout)
|
||||
}
|
||||
|
||||
func TestScriptReportsTiming(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
fLogger := newFakeScriptLogger()
|
||||
runner := setup(t, func(uuid2 uuid.UUID) agentscripts.ScriptLogger {
|
||||
return fLogger
|
||||
})
|
||||
|
||||
aAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
err := runner.Init([]codersdk.WorkspaceAgentScript{{
|
||||
DisplayName: "say-hello",
|
||||
LogSourceID: uuid.New(),
|
||||
Script: "echo hello",
|
||||
}}, aAPI.ScriptCompleted)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, runner.Execute(ctx, agentscripts.ExecuteAllScripts))
|
||||
runner.Close()
|
||||
|
||||
log := testutil.RequireRecvCtx(ctx, t, fLogger.logs)
|
||||
require.Equal(t, "hello", log.Output)
|
||||
|
||||
timings := aAPI.GetTimings()
|
||||
require.Equal(t, 1, len(timings))
|
||||
|
||||
timing := timings[0]
|
||||
require.Equal(t, int32(0), timing.ExitCode)
|
||||
require.GreaterOrEqual(t, timing.End.AsTime(), timing.Start.AsTime())
|
||||
}
|
||||
|
||||
// TestCronClose exists because cron.Run() can happen after cron.Close().
|
||||
|
||||
@@ -52,8 +52,16 @@ const (
|
||||
// MagicProcessCmdlineJetBrains is a string in a process's command line that
|
||||
// uniquely identifies it as JetBrains software.
|
||||
MagicProcessCmdlineJetBrains = "idea.vendor.name=JetBrains"
|
||||
|
||||
// BlockedFileTransferErrorCode indicates that SSH server restricted the raw command from performing
|
||||
// the file transfer.
|
||||
BlockedFileTransferErrorCode = 65 // Error code: host not allowed to connect
|
||||
BlockedFileTransferErrorMessage = "File transfer has been disabled."
|
||||
)
|
||||
|
||||
// BlockedFileTransferCommands contains a list of restricted file transfer commands.
|
||||
var BlockedFileTransferCommands = []string{"nc", "rsync", "scp", "sftp"}
|
||||
|
||||
// Config sets configuration parameters for the agent SSH server.
|
||||
type Config struct {
|
||||
// MaxTimeout sets the absolute connection timeout, none if empty. If set to
|
||||
@@ -71,9 +79,11 @@ type Config struct {
|
||||
// where users will land when they connect via SSH. Default is the home
|
||||
// directory of the user.
|
||||
WorkingDirectory func() string
|
||||
// X11SocketDir is the directory where X11 sockets are created. Default is
|
||||
// /tmp/.X11-unix.
|
||||
X11SocketDir string
|
||||
// X11DisplayOffset is the offset to add to the X11 display number.
|
||||
// Default is 10.
|
||||
X11DisplayOffset *int
|
||||
// BlockFileTransfer restricts use of file transfer applications.
|
||||
BlockFileTransfer bool
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
@@ -114,8 +124,9 @@ func NewServer(ctx context.Context, logger slog.Logger, prometheusRegistry *prom
|
||||
if config == nil {
|
||||
config = &Config{}
|
||||
}
|
||||
if config.X11SocketDir == "" {
|
||||
config.X11SocketDir = filepath.Join(os.TempDir(), ".X11-unix")
|
||||
if config.X11DisplayOffset == nil {
|
||||
offset := X11DefaultDisplayOffset
|
||||
config.X11DisplayOffset = &offset
|
||||
}
|
||||
if config.UpdateEnv == nil {
|
||||
config.UpdateEnv = func(current []string) ([]string, error) { return current, nil }
|
||||
@@ -263,13 +274,25 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
extraEnv := make([]string, 0)
|
||||
x11, hasX11 := session.X11()
|
||||
if hasX11 {
|
||||
handled := s.x11Handler(session.Context(), x11)
|
||||
display, handled := s.x11Handler(session.Context(), x11)
|
||||
if !handled {
|
||||
_ = session.Exit(1)
|
||||
logger.Error(ctx, "x11 handler failed")
|
||||
return
|
||||
}
|
||||
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=:%d.0", x11.ScreenNumber))
|
||||
extraEnv = append(extraEnv, fmt.Sprintf("DISPLAY=localhost:%d.%d", display, x11.ScreenNumber))
|
||||
}
|
||||
|
||||
if s.fileTransferBlocked(session) {
|
||||
s.logger.Warn(ctx, "file transfer blocked", slog.F("session_subsystem", session.Subsystem()), slog.F("raw_command", session.RawCommand()))
|
||||
|
||||
if session.Subsystem() == "" { // sftp does not expect error, otherwise it fails with "package too long"
|
||||
// Response format: <status_code><message body>\n
|
||||
errorMessage := fmt.Sprintf("\x02%s\n", BlockedFileTransferErrorMessage)
|
||||
_, _ = session.Write([]byte(errorMessage))
|
||||
}
|
||||
_ = session.Exit(BlockedFileTransferErrorCode)
|
||||
return
|
||||
}
|
||||
|
||||
switch ss := session.Subsystem(); ss {
|
||||
@@ -322,6 +345,37 @@ func (s *Server) sessionHandler(session ssh.Session) {
|
||||
_ = session.Exit(0)
|
||||
}
|
||||
|
||||
// fileTransferBlocked method checks if the file transfer commands should be blocked.
|
||||
//
|
||||
// Warning: consider this mechanism as "Do not trespass" sign, as a violator can still ssh to the host,
|
||||
// smuggle the `scp` binary, or just manually send files outside with `curl` or `ftp`.
|
||||
// If a user needs a more sophisticated and battle-proof solution, consider full endpoint security.
|
||||
func (s *Server) fileTransferBlocked(session ssh.Session) bool {
|
||||
if !s.config.BlockFileTransfer {
|
||||
return false // file transfers are permitted
|
||||
}
|
||||
// File transfers are restricted.
|
||||
|
||||
if session.Subsystem() == "sftp" {
|
||||
return true
|
||||
}
|
||||
|
||||
cmd := session.Command()
|
||||
if len(cmd) == 0 {
|
||||
return false // no command?
|
||||
}
|
||||
|
||||
c := cmd[0]
|
||||
c = filepath.Base(c) // in case the binary is absolute path, /usr/sbin/scp
|
||||
|
||||
for _, cmd := range BlockedFileTransferCommands {
|
||||
if cmd == c {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *Server) sessionStart(logger slog.Logger, session ssh.Session, extraEnv []string) (retErr error) {
|
||||
ctx := session.Context()
|
||||
env := append(session.Environ(), extraEnv...)
|
||||
|
||||
+91
-56
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -22,61 +23,69 @@ import (
|
||||
"cdr.dev/slog"
|
||||
)
|
||||
|
||||
const (
|
||||
// X11StartPort is the starting port for X11 forwarding, this is the
|
||||
// port used for "DISPLAY=localhost:0".
|
||||
X11StartPort = 6000
|
||||
// X11DefaultDisplayOffset is the default offset for X11 forwarding.
|
||||
X11DefaultDisplayOffset = 10
|
||||
)
|
||||
|
||||
// x11Callback is called when the client requests X11 forwarding.
|
||||
// It adds an Xauthority entry to the Xauthority file.
|
||||
func (s *Server) x11Callback(ctx ssh.Context, x11 ssh.X11) bool {
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to get hostname", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1)
|
||||
return false
|
||||
}
|
||||
|
||||
err = s.fs.MkdirAll(s.config.X11SocketDir, 0o700)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to make the x11 socket dir", slog.F("dir", s.config.X11SocketDir), slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("socker_dir").Add(1)
|
||||
return false
|
||||
}
|
||||
|
||||
err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(int(x11.ScreenNumber)), x11.AuthProtocol, x11.AuthCookie)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1)
|
||||
return false
|
||||
}
|
||||
func (*Server) x11Callback(_ ssh.Context, _ ssh.X11) bool {
|
||||
// Always allow.
|
||||
return true
|
||||
}
|
||||
|
||||
// x11Handler is called when a session has requested X11 forwarding.
|
||||
// It listens for X11 connections and forwards them to the client.
|
||||
func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) bool {
|
||||
func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) (displayNumber int, handled bool) {
|
||||
serverConn, valid := ctx.Value(ssh.ContextKeyConn).(*gossh.ServerConn)
|
||||
if !valid {
|
||||
s.logger.Warn(ctx, "failed to get server connection")
|
||||
return false
|
||||
return -1, false
|
||||
}
|
||||
// We want to overwrite the socket so that subsequent connections will succeed.
|
||||
socketPath := filepath.Join(s.config.X11SocketDir, fmt.Sprintf("X%d", x11.ScreenNumber))
|
||||
err := os.Remove(socketPath)
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
s.logger.Warn(ctx, "failed to remove existing X11 socket", slog.Error(err))
|
||||
return false
|
||||
}
|
||||
listener, err := net.Listen("unix", socketPath)
|
||||
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to listen for X11", slog.Error(err))
|
||||
return false
|
||||
s.logger.Warn(ctx, "failed to get hostname", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("hostname").Add(1)
|
||||
return -1, false
|
||||
}
|
||||
|
||||
ln, display, err := createX11Listener(ctx, *s.config.X11DisplayOffset)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to create X11 listener", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("listen").Add(1)
|
||||
return -1, false
|
||||
}
|
||||
s.trackListener(ln, true)
|
||||
defer func() {
|
||||
if !handled {
|
||||
s.trackListener(ln, false)
|
||||
_ = ln.Close()
|
||||
}
|
||||
}()
|
||||
|
||||
err = addXauthEntry(ctx, s.fs, hostname, strconv.Itoa(display), x11.AuthProtocol, x11.AuthCookie)
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to add Xauthority entry", slog.Error(err))
|
||||
s.metrics.x11HandlerErrors.WithLabelValues("xauthority").Add(1)
|
||||
return -1, false
|
||||
}
|
||||
s.trackListener(listener, true)
|
||||
|
||||
go func() {
|
||||
defer listener.Close()
|
||||
defer s.trackListener(listener, false)
|
||||
handledFirstConnection := false
|
||||
// Don't leave the listener open after the session is gone.
|
||||
<-ctx.Done()
|
||||
_ = ln.Close()
|
||||
}()
|
||||
|
||||
go func() {
|
||||
defer ln.Close()
|
||||
defer s.trackListener(ln, false)
|
||||
|
||||
for {
|
||||
conn, err := listener.Accept()
|
||||
conn, err := ln.Accept()
|
||||
if err != nil {
|
||||
if errors.Is(err, net.ErrClosed) {
|
||||
return
|
||||
@@ -84,40 +93,66 @@ func (s *Server) x11Handler(ctx ssh.Context, x11 ssh.X11) bool {
|
||||
s.logger.Warn(ctx, "failed to accept X11 connection", slog.Error(err))
|
||||
return
|
||||
}
|
||||
if x11.SingleConnection && handledFirstConnection {
|
||||
s.logger.Warn(ctx, "X11 connection rejected because single connection is enabled")
|
||||
if x11.SingleConnection {
|
||||
s.logger.Debug(ctx, "single connection requested, closing X11 listener")
|
||||
_ = ln.Close()
|
||||
}
|
||||
|
||||
tcpConn, ok := conn.(*net.TCPConn)
|
||||
if !ok {
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to TCPConn. got: %T", conn))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
handledFirstConnection = true
|
||||
|
||||
unixConn, ok := conn.(*net.UnixConn)
|
||||
tcpAddr, ok := tcpConn.LocalAddr().(*net.TCPAddr)
|
||||
if !ok {
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast connection to UnixConn. got: %T", conn))
|
||||
return
|
||||
}
|
||||
unixAddr, ok := unixConn.LocalAddr().(*net.UnixAddr)
|
||||
if !ok {
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to UnixAddr. got: %T", unixConn.LocalAddr()))
|
||||
return
|
||||
s.logger.Warn(ctx, fmt.Sprintf("failed to cast local address to TCPAddr. got: %T", tcpConn.LocalAddr()))
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
|
||||
channel, reqs, err := serverConn.OpenChannel("x11", gossh.Marshal(struct {
|
||||
OriginatorAddress string
|
||||
OriginatorPort uint32
|
||||
}{
|
||||
OriginatorAddress: unixAddr.Name,
|
||||
OriginatorPort: 0,
|
||||
OriginatorAddress: tcpAddr.IP.String(),
|
||||
OriginatorPort: uint32(tcpAddr.Port),
|
||||
}))
|
||||
if err != nil {
|
||||
s.logger.Warn(ctx, "failed to open X11 channel", slog.Error(err))
|
||||
return
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go gossh.DiscardRequests(reqs)
|
||||
go Bicopy(ctx, conn, channel)
|
||||
|
||||
if !s.trackConn(ln, conn, true) {
|
||||
s.logger.Warn(ctx, "failed to track X11 connection")
|
||||
_ = conn.Close()
|
||||
continue
|
||||
}
|
||||
go func() {
|
||||
defer s.trackConn(ln, conn, false)
|
||||
Bicopy(ctx, conn, channel)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
return true
|
||||
|
||||
return display, true
|
||||
}
|
||||
|
||||
// createX11Listener creates a listener for X11 forwarding, it will use
|
||||
// the next available port starting from X11StartPort and displayOffset.
|
||||
func createX11Listener(ctx context.Context, displayOffset int) (ln net.Listener, display int, err error) {
|
||||
var lc net.ListenConfig
|
||||
// Look for an open port to listen on.
|
||||
for port := X11StartPort + displayOffset; port < math.MaxUint16; port++ {
|
||||
ln, err = lc.Listen(ctx, "tcp", fmt.Sprintf("localhost:%d", port))
|
||||
if err == nil {
|
||||
display = port - X11StartPort
|
||||
return ln, display, nil
|
||||
}
|
||||
}
|
||||
return nil, -1, xerrors.Errorf("failed to find open port for X11 listener: %w", err)
|
||||
}
|
||||
|
||||
// addXauthEntry adds an Xauthority entry to the Xauthority file.
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
package agentssh_test
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gliderlabs/ssh"
|
||||
@@ -31,10 +36,7 @@ func TestServer_X11(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
logger := slogtest.Make(t, nil).Leveled(slog.LevelDebug)
|
||||
fs := afero.NewOsFs()
|
||||
dir := t.TempDir()
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, &agentssh.Config{
|
||||
X11SocketDir: dir,
|
||||
})
|
||||
s, err := agentssh.NewServer(ctx, logger, prometheus.NewRegistry(), fs, &agentssh.Config{})
|
||||
require.NoError(t, err)
|
||||
defer s.Close()
|
||||
|
||||
@@ -53,21 +55,45 @@ func TestServer_X11(t *testing.T) {
|
||||
sess, err := c.NewSession()
|
||||
require.NoError(t, err)
|
||||
|
||||
wantScreenNumber := 1
|
||||
reply, err := sess.SendRequest("x11-req", true, gossh.Marshal(ssh.X11{
|
||||
AuthProtocol: "MIT-MAGIC-COOKIE-1",
|
||||
AuthCookie: hex.EncodeToString([]byte("cookie")),
|
||||
ScreenNumber: 0,
|
||||
ScreenNumber: uint32(wantScreenNumber),
|
||||
}))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, reply)
|
||||
|
||||
err = sess.Shell()
|
||||
// Want: ~DISPLAY=localhost:10.1
|
||||
out, err := sess.Output("echo DISPLAY=$DISPLAY")
|
||||
require.NoError(t, err)
|
||||
|
||||
sc := bufio.NewScanner(bytes.NewReader(out))
|
||||
displayNumber := -1
|
||||
for sc.Scan() {
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
t.Log(line)
|
||||
if strings.HasPrefix(line, "DISPLAY=") {
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
display := parts[1]
|
||||
parts = strings.SplitN(display, ":", 2)
|
||||
parts = strings.SplitN(parts[1], ".", 2)
|
||||
displayNumber, err = strconv.Atoi(parts[0])
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, displayNumber, 10, "display number should be >= 10")
|
||||
gotScreenNumber, err := strconv.Atoi(parts[1])
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, wantScreenNumber, gotScreenNumber, "screen number should match")
|
||||
break
|
||||
}
|
||||
}
|
||||
require.NoError(t, sc.Err())
|
||||
require.NotEqual(t, -1, displayNumber)
|
||||
|
||||
x11Chans := c.HandleChannelOpen("x11")
|
||||
payload := "hello world"
|
||||
require.Eventually(t, func() bool {
|
||||
conn, err := net.Dial("unix", filepath.Join(dir, "X0"))
|
||||
conn, err := net.Dial("tcp", fmt.Sprintf("localhost:%d", agentssh.X11StartPort+displayNumber))
|
||||
if err == nil {
|
||||
_, err = conn.Write([]byte(payload))
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -170,6 +170,7 @@ type FakeAgentAPI struct {
|
||||
logsCh chan<- *agentproto.BatchCreateLogsRequest
|
||||
lifecycleStates []codersdk.WorkspaceAgentLifecycle
|
||||
metadata map[string]agentsdk.Metadata
|
||||
timings []*agentproto.Timing
|
||||
|
||||
getAnnouncementBannersFunc func() ([]codersdk.BannerConfig, error)
|
||||
}
|
||||
@@ -182,6 +183,12 @@ func (*FakeAgentAPI) GetServiceBanner(context.Context, *agentproto.GetServiceBan
|
||||
return &agentproto.ServiceBanner{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetTimings() []*agentproto.Timing {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
return slices.Clone(f.timings)
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) SetAnnouncementBannersFunc(fn func() ([]codersdk.BannerConfig, error)) {
|
||||
f.Lock()
|
||||
defer f.Unlock()
|
||||
@@ -210,7 +217,12 @@ func (f *FakeAgentAPI) UpdateStats(ctx context.Context, req *agentproto.UpdateSt
|
||||
f.logger.Debug(ctx, "update stats called", slog.F("req", req))
|
||||
// empty request is sent to get the interval; but our tests don't want empty stats requests
|
||||
if req.Stats != nil {
|
||||
f.statsCh <- req.Stats
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case f.statsCh <- req.Stats:
|
||||
// OK!
|
||||
}
|
||||
}
|
||||
return &agentproto.UpdateStatsResponse{ReportInterval: durationpb.New(statsInterval)}, nil
|
||||
}
|
||||
@@ -233,17 +245,25 @@ func (f *FakeAgentAPI) UpdateLifecycle(_ context.Context, req *agentproto.Update
|
||||
|
||||
func (f *FakeAgentAPI) BatchUpdateAppHealths(ctx context.Context, req *agentproto.BatchUpdateAppHealthRequest) (*agentproto.BatchUpdateAppHealthResponse, error) {
|
||||
f.logger.Debug(ctx, "batch update app health", slog.F("req", req))
|
||||
f.appHealthCh <- req
|
||||
return &agentproto.BatchUpdateAppHealthResponse{}, nil
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case f.appHealthCh <- req:
|
||||
return &agentproto.BatchUpdateAppHealthResponse{}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) AppHealthCh() <-chan *agentproto.BatchUpdateAppHealthRequest {
|
||||
return f.appHealthCh
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) UpdateStartup(_ context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
|
||||
f.startupCh <- req.GetStartup()
|
||||
return req.GetStartup(), nil
|
||||
func (f *FakeAgentAPI) UpdateStartup(ctx context.Context, req *agentproto.UpdateStartupRequest) (*agentproto.Startup, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case f.startupCh <- req.GetStartup():
|
||||
return req.GetStartup(), nil
|
||||
}
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) GetMetadata() map[string]agentsdk.Metadata {
|
||||
@@ -288,6 +308,14 @@ func (f *FakeAgentAPI) BatchCreateLogs(ctx context.Context, req *agentproto.Batc
|
||||
return &agentproto.BatchCreateLogsResponse{}, nil
|
||||
}
|
||||
|
||||
func (f *FakeAgentAPI) ScriptCompleted(_ context.Context, req *agentproto.WorkspaceAgentScriptCompletedRequest) (*agentproto.WorkspaceAgentScriptCompletedResponse, error) {
|
||||
f.Lock()
|
||||
f.timings = append(f.timings, req.Timing)
|
||||
f.Unlock()
|
||||
|
||||
return &agentproto.WorkspaceAgentScriptCompletedResponse{}, nil
|
||||
}
|
||||
|
||||
func NewFakeAgentAPI(t testing.TB, logger slog.Logger, manifest *agentproto.Manifest, statsCh chan *agentproto.Stats) *FakeAgentAPI {
|
||||
return &FakeAgentAPI{
|
||||
t: t,
|
||||
|
||||
@@ -37,6 +37,7 @@ func (a *agent) apiHandler() http.Handler {
|
||||
}
|
||||
promHandler := PrometheusMetricsHandler(a.prometheusRegistry, a.logger)
|
||||
r.Get("/api/v0/listening-ports", lp.handler)
|
||||
r.Get("/api/v0/netcheck", a.HandleNetcheck)
|
||||
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
|
||||
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
|
||||
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)
|
||||
|
||||
+53
-59
@@ -12,12 +12,9 @@ import (
|
||||
"cdr.dev/slog"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/retry"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
// WorkspaceAgentApps fetches the workspace apps.
|
||||
type WorkspaceAgentApps func(context.Context) ([]codersdk.WorkspaceApp, error)
|
||||
|
||||
// PostWorkspaceAgentAppHealth updates the workspace app health.
|
||||
type PostWorkspaceAgentAppHealth func(context.Context, agentsdk.PostAppHealthsRequest) error
|
||||
|
||||
@@ -26,15 +23,26 @@ type WorkspaceAppHealthReporter func(ctx context.Context)
|
||||
|
||||
// NewWorkspaceAppHealthReporter creates a WorkspaceAppHealthReporter that reports app health to coderd.
|
||||
func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.WorkspaceApp, postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth) WorkspaceAppHealthReporter {
|
||||
return NewAppHealthReporterWithClock(logger, apps, postWorkspaceAgentAppHealth, quartz.NewReal())
|
||||
}
|
||||
|
||||
// NewAppHealthReporterWithClock is only called directly by test code. Product code should call
|
||||
// NewAppHealthReporter.
|
||||
func NewAppHealthReporterWithClock(
|
||||
logger slog.Logger,
|
||||
apps []codersdk.WorkspaceApp,
|
||||
postWorkspaceAgentAppHealth PostWorkspaceAgentAppHealth,
|
||||
clk quartz.Clock,
|
||||
) WorkspaceAppHealthReporter {
|
||||
logger = logger.Named("apphealth")
|
||||
|
||||
runHealthcheckLoop := func(ctx context.Context) error {
|
||||
return func(ctx context.Context) {
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
defer cancel()
|
||||
|
||||
// no need to run this loop if no apps for this workspace.
|
||||
if len(apps) == 0 {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
hasHealthchecksEnabled := false
|
||||
@@ -49,7 +57,7 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
|
||||
// no need to run this loop if no health checks are configured.
|
||||
if !hasHealthchecksEnabled {
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
// run a ticker for each app health check.
|
||||
@@ -61,25 +69,29 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
}
|
||||
app := nextApp
|
||||
go func() {
|
||||
t := time.NewTicker(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
defer t.Stop()
|
||||
_ = clk.TickerFunc(ctx, time.Duration(app.Healthcheck.Interval)*time.Second, func() error {
|
||||
// We time out at the healthcheck interval to prevent getting too backed up, but
|
||||
// set it 1ms early so that it's not simultaneous with the next tick in testing,
|
||||
// which makes the test easier to understand.
|
||||
//
|
||||
// It would be idiomatic to use the http.Client.Timeout or a context.WithTimeout,
|
||||
// but we are passing this off to the native http library, which is not aware
|
||||
// of the clock library we are using. That means in testing, with a mock clock
|
||||
// it will compare mocked times with real times, and we will get strange results.
|
||||
// So, we just implement the timeout as a context we cancel with an AfterFunc
|
||||
reqCtx, reqCancel := context.WithCancel(ctx)
|
||||
timeout := clk.AfterFunc(
|
||||
time.Duration(app.Healthcheck.Interval)*time.Second-time.Millisecond,
|
||||
reqCancel,
|
||||
"timeout", app.Slug)
|
||||
defer timeout.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
}
|
||||
// we set the http timeout to the healthcheck interval to prevent getting too backed up.
|
||||
client := &http.Client{
|
||||
Timeout: time.Duration(app.Healthcheck.Interval) * time.Second,
|
||||
}
|
||||
err := func() error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, app.Healthcheck.URL, nil)
|
||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, app.Healthcheck.URL, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := client.Do(req)
|
||||
res, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,54 +130,36 @@ func NewWorkspaceAppHealthReporter(logger slog.Logger, apps []codersdk.Workspace
|
||||
mu.Unlock()
|
||||
logger.Debug(ctx, "workspace app healthy", slog.F("id", app.ID.String()), slog.F("slug", app.Slug))
|
||||
}
|
||||
|
||||
t.Reset(time.Duration(app.Healthcheck.Interval) * time.Second)
|
||||
}
|
||||
return nil
|
||||
}, "healthcheck", app.Slug)
|
||||
}()
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
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 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
reportTicker := clk.TickerFunc(ctx, time.Second, func() error {
|
||||
mu.RLock()
|
||||
changed := healthChanged(lastHealth, health)
|
||||
mu.RUnlock()
|
||||
if !changed {
|
||||
return nil
|
||||
case <-reportTicker.C:
|
||||
mu.RLock()
|
||||
changed := healthChanged(lastHealth, health)
|
||||
mu.RUnlock()
|
||||
if !changed {
|
||||
continue
|
||||
}
|
||||
|
||||
mu.Lock()
|
||||
lastHealth = copyHealth(health)
|
||||
mu.Unlock()
|
||||
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: lastHealth,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
|
||||
} else {
|
||||
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return func(ctx context.Context) {
|
||||
for r := retry.New(time.Second, 30*time.Second); r.Wait(ctx); {
|
||||
err := runHealthcheckLoop(ctx)
|
||||
if err == nil || xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) {
|
||||
return
|
||||
mu.Lock()
|
||||
lastHealth = copyHealth(health)
|
||||
mu.Unlock()
|
||||
err := postWorkspaceAgentAppHealth(ctx, agentsdk.PostAppHealthsRequest{
|
||||
Healths: lastHealth,
|
||||
})
|
||||
if err != nil {
|
||||
logger.Error(ctx, "failed to report workspace app health", slog.Error(err))
|
||||
} else {
|
||||
logger.Debug(ctx, "sent workspace app health", slog.F("health", lastHealth))
|
||||
}
|
||||
logger.Error(ctx, "failed running workspace app reporter", slog.Error(err))
|
||||
}
|
||||
return nil
|
||||
}, "report")
|
||||
_ = reportTicker.Wait() // only possible error is context done
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+154
-113
@@ -4,14 +4,12 @@ import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog"
|
||||
@@ -23,19 +21,22 @@ import (
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/agentsdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/quartz"
|
||||
)
|
||||
|
||||
func TestAppHealth_Healthy(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.UUID{1},
|
||||
Slug: "app1",
|
||||
Healthcheck: codersdk.Healthcheck{},
|
||||
Health: codersdk.WorkspaceAppHealthDisabled,
|
||||
},
|
||||
{
|
||||
ID: uuid.UUID{2},
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
@@ -46,6 +47,7 @@ func TestAppHealth_Healthy(t *testing.T) {
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
{
|
||||
ID: uuid.UUID{3},
|
||||
Slug: "app3",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
Interval: 2,
|
||||
@@ -54,36 +56,71 @@ func TestAppHealth_Healthy(t *testing.T) {
|
||||
Health: codersdk.WorkspaceAppHealthInitializing,
|
||||
},
|
||||
}
|
||||
checks2 := 0
|
||||
checks3 := 0
|
||||
handlers := []http.Handler{
|
||||
nil,
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checks2++
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
checks3++
|
||||
httpapi.Write(r.Context(), w, http.StatusOK, nil)
|
||||
}),
|
||||
}
|
||||
getApps, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
defer closeFn()
|
||||
apps, err := getApps(ctx)
|
||||
require.NoError(t, err)
|
||||
require.EqualValues(t, codersdk.WorkspaceAppHealthDisabled, apps[0].Health)
|
||||
require.Eventually(t, func() bool {
|
||||
apps, err := getApps(ctx)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
mClock := quartz.NewMock(t)
|
||||
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
|
||||
defer healthcheckTrap.Close()
|
||||
reportTrap := mClock.Trap().TickerFunc("report")
|
||||
defer reportTrap.Close()
|
||||
|
||||
return apps[1].Health == codersdk.WorkspaceAppHealthHealthy && apps[2].Health == codersdk.WorkspaceAppHealthHealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
|
||||
defer closeFn()
|
||||
healthchecksStarted := make([]string, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
c := healthcheckTrap.MustWait(ctx)
|
||||
c.Release()
|
||||
healthchecksStarted[i] = c.Tags[1]
|
||||
}
|
||||
slices.Sort(healthchecksStarted)
|
||||
require.Equal(t, []string{"app2", "app3"}, healthchecksStarted)
|
||||
|
||||
// advance the clock 1ms before the report ticker starts, so that it's not
|
||||
// simultaneous with the checks.
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx)
|
||||
reportTrap.MustWait(ctx).Release()
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app2 is now healthy
|
||||
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 2)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthInitializing, apps[2].Health)
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // app3 is now healthy
|
||||
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered
|
||||
update = testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 2)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[1].Health)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthHealthy, apps[2].Health)
|
||||
|
||||
// ensure we aren't spamming
|
||||
require.Equal(t, 2, checks2)
|
||||
require.Equal(t, 1, checks3)
|
||||
}
|
||||
|
||||
func TestAppHealth_500(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.UUID{2},
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
@@ -99,59 +136,40 @@ func TestAppHealth_500(t *testing.T) {
|
||||
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)
|
||||
mClock := quartz.NewMock(t)
|
||||
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
|
||||
defer healthcheckTrap.Close()
|
||||
reportTrap := mClock.Trap().TickerFunc("report")
|
||||
defer reportTrap.Close()
|
||||
|
||||
fakeAPI, closeFn := setupAppReporter(ctx, t, slices.Clone(apps), handlers, mClock)
|
||||
defer closeFn()
|
||||
healthcheckTrap.MustWait(ctx).Release()
|
||||
// advance the clock 1ms before the report ticker starts, so that it's not
|
||||
// simultaneous with the checks.
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx)
|
||||
reportTrap.MustWait(ctx).Release()
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // check gets triggered
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // report gets triggered, but unsent since we are at the threshold
|
||||
|
||||
mClock.Advance(999 * time.Millisecond).MustWait(ctx) // 2nd check, crosses threshold
|
||||
mClock.Advance(time.Millisecond).MustWait(ctx) // 2nd report, sends update
|
||||
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 1)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
return apps[0].Health == codersdk.WorkspaceAppHealthUnhealthy
|
||||
}, testutil.WaitLong, testutil.IntervalSlow)
|
||||
}
|
||||
|
||||
func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
t.Parallel()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
defer cancel()
|
||||
apps := []codersdk.WorkspaceApp{
|
||||
{
|
||||
ID: uuid.UUID{2},
|
||||
Slug: "app2",
|
||||
Healthcheck: codersdk.Healthcheck{
|
||||
// URL: We don't set the URL for this test because the setup will
|
||||
@@ -163,27 +181,65 @@ func TestAppHealth_NotSpamming(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
counter := new(int32)
|
||||
handlers := []http.Handler{
|
||||
http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(counter, 1)
|
||||
http.HandlerFunc(func(_ http.ResponseWriter, r *http.Request) {
|
||||
// allow the request to time out
|
||||
<-r.Context().Done()
|
||||
}),
|
||||
}
|
||||
_, closeFn := setupAppReporter(ctx, t, apps, handlers)
|
||||
mClock := quartz.NewMock(t)
|
||||
start := mClock.Now()
|
||||
|
||||
// for this test, it's easier to think in the number of milliseconds elapsed
|
||||
// since start.
|
||||
ms := func(n int) time.Time {
|
||||
return start.Add(time.Duration(n) * time.Millisecond)
|
||||
}
|
||||
healthcheckTrap := mClock.Trap().TickerFunc("healthcheck")
|
||||
defer healthcheckTrap.Close()
|
||||
reportTrap := mClock.Trap().TickerFunc("report")
|
||||
defer reportTrap.Close()
|
||||
timeoutTrap := mClock.Trap().AfterFunc("timeout")
|
||||
defer timeoutTrap.Close()
|
||||
|
||||
fakeAPI, closeFn := setupAppReporter(ctx, t, apps, handlers, mClock)
|
||||
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, atomic.LoadInt32(counter), int32(2))
|
||||
healthcheckTrap.MustWait(ctx).Release()
|
||||
// advance the clock 1ms before the report ticker starts, so that it's not
|
||||
// simultaneous with the checks.
|
||||
mClock.Set(ms(1)).MustWait(ctx)
|
||||
reportTrap.MustWait(ctx).Release()
|
||||
|
||||
w := mClock.Set(ms(1000)) // 1st check starts
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(1001)).MustWait(ctx) // report tick, no change
|
||||
mClock.Set(ms(1999)) // timeout pops
|
||||
w.MustWait(ctx) // 1st check finished
|
||||
w = mClock.Set(ms(2000)) // 2nd check starts
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(2001)).MustWait(ctx) // report tick, no change
|
||||
mClock.Set(ms(2999)) // timeout pops
|
||||
w.MustWait(ctx) // 2nd check finished
|
||||
// app is now unhealthy after 2 timeouts
|
||||
mClock.Set(ms(3000)) // 3rd check starts
|
||||
timeoutTrap.MustWait(ctx).Release()
|
||||
mClock.Set(ms(3001)).MustWait(ctx) // report tick, sends changes
|
||||
|
||||
update := testutil.RequireRecvCtx(ctx, t, fakeAPI.AppHealthCh())
|
||||
require.Len(t, update.GetUpdates(), 1)
|
||||
applyUpdate(t, apps, update)
|
||||
require.Equal(t, codersdk.WorkspaceAppHealthUnhealthy, apps[0].Health)
|
||||
}
|
||||
|
||||
func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.WorkspaceApp, handlers []http.Handler) (agent.WorkspaceAgentApps, func()) {
|
||||
func setupAppReporter(
|
||||
ctx context.Context, t *testing.T,
|
||||
apps []codersdk.WorkspaceApp,
|
||||
handlers []http.Handler,
|
||||
clk quartz.Clock,
|
||||
) (*agenttest.FakeAgentAPI, func()) {
|
||||
closers := []func(){}
|
||||
for i, app := range apps {
|
||||
if app.ID == uuid.Nil {
|
||||
app.ID = uuid.New()
|
||||
apps[i] = app
|
||||
}
|
||||
for _, app := range apps {
|
||||
require.NotEqual(t, uuid.Nil, app.ID, "all apps must have ID set")
|
||||
}
|
||||
for i, handler := range handlers {
|
||||
if handler == nil {
|
||||
@@ -196,14 +252,6 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
closers = append(closers, ts.Close)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
workspaceAgentApps := func(context.Context) ([]codersdk.WorkspaceApp, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
var newApps []codersdk.WorkspaceApp
|
||||
return append(newApps, apps...), nil
|
||||
}
|
||||
|
||||
// We don't care about manifest or stats in this test since it's not using
|
||||
// a full agent and these RPCs won't get called.
|
||||
//
|
||||
@@ -212,38 +260,31 @@ func setupAppReporter(ctx context.Context, t *testing.T, apps []codersdk.Workspa
|
||||
// post function.
|
||||
fakeAAPI := agenttest.NewFakeAgentAPI(t, slogtest.Make(t, nil), nil, nil)
|
||||
|
||||
// Process events from the channel and update the health of the apps.
|
||||
go func() {
|
||||
appHealthCh := fakeAAPI.AppHealthCh()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case req := <-appHealthCh:
|
||||
mu.Lock()
|
||||
for _, update := range req.Updates {
|
||||
updateID, err := uuid.FromBytes(update.Id)
|
||||
assert.NoError(t, err)
|
||||
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
|
||||
go agent.NewAppHealthReporterWithClock(
|
||||
slogtest.Make(t, nil).Leveled(slog.LevelDebug),
|
||||
apps, agentsdk.AppHealthPoster(fakeAAPI), clk,
|
||||
)(ctx)
|
||||
|
||||
for i, app := range apps {
|
||||
if app.ID != updateID {
|
||||
continue
|
||||
}
|
||||
app.Health = updateHealth
|
||||
apps[i] = app
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
go agent.NewWorkspaceAppHealthReporter(slogtest.Make(t, nil).Leveled(slog.LevelDebug), apps, agentsdk.AppHealthPoster(fakeAAPI))(ctx)
|
||||
|
||||
return workspaceAgentApps, func() {
|
||||
return fakeAAPI, func() {
|
||||
for _, closeFn := range closers {
|
||||
closeFn()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyUpdate(t *testing.T, apps []codersdk.WorkspaceApp, req *proto.BatchUpdateAppHealthRequest) {
|
||||
t.Helper()
|
||||
for _, update := range req.Updates {
|
||||
updateID, err := uuid.FromBytes(update.Id)
|
||||
require.NoError(t, err)
|
||||
updateHealth := codersdk.WorkspaceAppHealth(strings.ToLower(proto.AppHealth_name[int32(update.Health)]))
|
||||
|
||||
for i, app := range apps {
|
||||
if app.ID != updateID {
|
||||
continue
|
||||
}
|
||||
app.Health = updateHealth
|
||||
apps[i] = app
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/health"
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
)
|
||||
|
||||
func (a *agent) HandleNetcheck(rw http.ResponseWriter, r *http.Request) {
|
||||
ni := a.TailnetConn().GetNetInfo()
|
||||
|
||||
ifReport, err := healthsdk.RunInterfacesReport()
|
||||
if err != nil {
|
||||
httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{
|
||||
Message: "Failed to run interfaces report",
|
||||
Detail: err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
httpapi.Write(r.Context(), rw, http.StatusOK, healthsdk.AgentNetcheckReport{
|
||||
BaseReport: healthsdk.BaseReport{
|
||||
Severity: health.SeverityOK,
|
||||
},
|
||||
NetInfo: ni,
|
||||
Interfaces: ifReport,
|
||||
})
|
||||
}
|
||||
@@ -19,6 +19,7 @@ type agentMetrics struct {
|
||||
// startupScriptSeconds is the time in seconds that the start script(s)
|
||||
// took to run. This is reported once per agent.
|
||||
startupScriptSeconds *prometheus.GaugeVec
|
||||
currentConnections *prometheus.GaugeVec
|
||||
}
|
||||
|
||||
func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
|
||||
@@ -45,10 +46,19 @@ func newAgentMetrics(registerer prometheus.Registerer) *agentMetrics {
|
||||
}, []string{"success"})
|
||||
registerer.MustRegister(startupScriptSeconds)
|
||||
|
||||
currentConnections := prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: "coderd",
|
||||
Subsystem: "agentstats",
|
||||
Name: "currently_reachable_peers",
|
||||
Help: "The number of peers (e.g. clients) that are currently reachable over the encrypted network.",
|
||||
}, []string{"connection_type"})
|
||||
registerer.MustRegister(currentConnections)
|
||||
|
||||
return &agentMetrics{
|
||||
connectionsTotal: connectionsTotal,
|
||||
reconnectingPTYErrors: reconnectingPTYErrors,
|
||||
startupScriptSeconds: startupScriptSeconds,
|
||||
currentConnections: currentConnections,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+881
-491
File diff suppressed because it is too large
Load Diff
@@ -41,6 +41,7 @@ message WorkspaceApp {
|
||||
UNHEALTHY = 4;
|
||||
}
|
||||
Health health = 12;
|
||||
bool hidden = 13;
|
||||
}
|
||||
|
||||
message WorkspaceAgentScript {
|
||||
@@ -52,6 +53,8 @@ message WorkspaceAgentScript {
|
||||
bool run_on_stop = 6;
|
||||
bool start_blocks_login = 7;
|
||||
google.protobuf.Duration timeout = 8;
|
||||
string display_name = 9;
|
||||
bytes id = 10;
|
||||
}
|
||||
|
||||
message WorkspaceAgentMetadata {
|
||||
@@ -263,6 +266,35 @@ message BannerConfig {
|
||||
string background_color = 3;
|
||||
}
|
||||
|
||||
message WorkspaceAgentScriptCompletedRequest {
|
||||
Timing timing = 1;
|
||||
}
|
||||
|
||||
message WorkspaceAgentScriptCompletedResponse {
|
||||
}
|
||||
|
||||
message Timing {
|
||||
bytes script_id = 1;
|
||||
google.protobuf.Timestamp start = 2;
|
||||
google.protobuf.Timestamp end = 3;
|
||||
int32 exit_code = 4;
|
||||
|
||||
enum Stage {
|
||||
START = 0;
|
||||
STOP = 1;
|
||||
CRON = 2;
|
||||
}
|
||||
Stage stage = 5;
|
||||
|
||||
enum Status {
|
||||
OK = 0;
|
||||
EXIT_FAILURE = 1;
|
||||
TIMED_OUT = 2;
|
||||
PIPES_LEFT_OPEN = 3;
|
||||
}
|
||||
Status status = 6;
|
||||
}
|
||||
|
||||
service Agent {
|
||||
rpc GetManifest(GetManifestRequest) returns (Manifest);
|
||||
rpc GetServiceBanner(GetServiceBannerRequest) returns (ServiceBanner);
|
||||
@@ -273,4 +305,5 @@ service Agent {
|
||||
rpc BatchUpdateMetadata(BatchUpdateMetadataRequest) returns (BatchUpdateMetadataResponse);
|
||||
rpc BatchCreateLogs(BatchCreateLogsRequest) returns (BatchCreateLogsResponse);
|
||||
rpc GetAnnouncementBanners(GetAnnouncementBannersRequest) returns (GetAnnouncementBannersResponse);
|
||||
rpc ScriptCompleted(WorkspaceAgentScriptCompletedRequest) returns (WorkspaceAgentScriptCompletedResponse);
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ type DRPCAgentClient interface {
|
||||
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(ctx context.Context, in *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||
}
|
||||
|
||||
type drpcAgentClient struct {
|
||||
@@ -140,6 +141,15 @@ func (c *drpcAgentClient) GetAnnouncementBanners(ctx context.Context, in *GetAnn
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *drpcAgentClient) ScriptCompleted(ctx context.Context, in *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error) {
|
||||
out := new(WorkspaceAgentScriptCompletedResponse)
|
||||
err := c.cc.Invoke(ctx, "/coder.agent.v2.Agent/ScriptCompleted", drpcEncoding_File_agent_proto_agent_proto{}, in, out)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type DRPCAgentServer interface {
|
||||
GetManifest(context.Context, *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(context.Context, *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
@@ -150,6 +160,7 @@ type DRPCAgentServer interface {
|
||||
BatchUpdateMetadata(context.Context, *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(context.Context, *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(context.Context, *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
ScriptCompleted(context.Context, *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error)
|
||||
}
|
||||
|
||||
type DRPCAgentUnimplementedServer struct{}
|
||||
@@ -190,9 +201,13 @@ func (s *DRPCAgentUnimplementedServer) GetAnnouncementBanners(context.Context, *
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
func (s *DRPCAgentUnimplementedServer) ScriptCompleted(context.Context, *WorkspaceAgentScriptCompletedRequest) (*WorkspaceAgentScriptCompletedResponse, error) {
|
||||
return nil, drpcerr.WithCode(errors.New("Unimplemented"), drpcerr.Unimplemented)
|
||||
}
|
||||
|
||||
type DRPCAgentDescription struct{}
|
||||
|
||||
func (DRPCAgentDescription) NumMethods() int { return 9 }
|
||||
func (DRPCAgentDescription) NumMethods() int { return 10 }
|
||||
|
||||
func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver, interface{}, bool) {
|
||||
switch n {
|
||||
@@ -277,6 +292,15 @@ func (DRPCAgentDescription) Method(n int) (string, drpc.Encoding, drpc.Receiver,
|
||||
in1.(*GetAnnouncementBannersRequest),
|
||||
)
|
||||
}, DRPCAgentServer.GetAnnouncementBanners, true
|
||||
case 9:
|
||||
return "/coder.agent.v2.Agent/ScriptCompleted", drpcEncoding_File_agent_proto_agent_proto{},
|
||||
func(srv interface{}, ctx context.Context, in1, in2 interface{}) (drpc.Message, error) {
|
||||
return srv.(DRPCAgentServer).
|
||||
ScriptCompleted(
|
||||
ctx,
|
||||
in1.(*WorkspaceAgentScriptCompletedRequest),
|
||||
)
|
||||
}, DRPCAgentServer.ScriptCompleted, true
|
||||
default:
|
||||
return "", nil, nil, nil, false
|
||||
}
|
||||
@@ -429,3 +453,19 @@ func (x *drpcAgent_GetAnnouncementBannersStream) SendAndClose(m *GetAnnouncement
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
type DRPCAgent_ScriptCompletedStream interface {
|
||||
drpc.Stream
|
||||
SendAndClose(*WorkspaceAgentScriptCompletedResponse) error
|
||||
}
|
||||
|
||||
type drpcAgent_ScriptCompletedStream struct {
|
||||
drpc.Stream
|
||||
}
|
||||
|
||||
func (x *drpcAgent_ScriptCompletedStream) SendAndClose(m *WorkspaceAgentScriptCompletedResponse) error {
|
||||
if err := x.MsgSend(m, drpcEncoding_File_agent_proto_agent_proto{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return x.CloseSend()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
package proto
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"storj.io/drpc"
|
||||
)
|
||||
|
||||
// DRPCAgentClient20 is the Agent API at v2.0. Notably, it is missing GetAnnouncementBanners, but
|
||||
// is useful when you want to be maximally compatible with Coderd Release Versions from 2.9+
|
||||
type DRPCAgentClient20 interface {
|
||||
DRPCConn() drpc.Conn
|
||||
|
||||
GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error)
|
||||
UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error)
|
||||
BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error)
|
||||
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
|
||||
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
}
|
||||
|
||||
// DRPCAgentClient21 is the Agent API at v2.1. It is useful if you want to be maximally compatible
|
||||
// with Coderd Release Versions from 2.12+
|
||||
type DRPCAgentClient21 interface {
|
||||
DRPCConn() drpc.Conn
|
||||
|
||||
GetManifest(ctx context.Context, in *GetManifestRequest) (*Manifest, error)
|
||||
GetServiceBanner(ctx context.Context, in *GetServiceBannerRequest) (*ServiceBanner, error)
|
||||
UpdateStats(ctx context.Context, in *UpdateStatsRequest) (*UpdateStatsResponse, error)
|
||||
UpdateLifecycle(ctx context.Context, in *UpdateLifecycleRequest) (*Lifecycle, error)
|
||||
BatchUpdateAppHealths(ctx context.Context, in *BatchUpdateAppHealthRequest) (*BatchUpdateAppHealthResponse, error)
|
||||
UpdateStartup(ctx context.Context, in *UpdateStartupRequest) (*Startup, error)
|
||||
BatchUpdateMetadata(ctx context.Context, in *BatchUpdateMetadataRequest) (*BatchUpdateMetadataResponse, error)
|
||||
BatchCreateLogs(ctx context.Context, in *BatchCreateLogsRequest) (*BatchCreateLogsResponse, error)
|
||||
GetAnnouncementBanners(ctx context.Context, in *GetAnnouncementBannersRequest) (*GetAnnouncementBannersResponse, error)
|
||||
}
|
||||
@@ -26,7 +26,7 @@ type APIVersion struct {
|
||||
}
|
||||
|
||||
func (v *APIVersion) WithBackwardCompat(majs ...int) *APIVersion {
|
||||
v.additionalMajors = append(v.additionalMajors, majs[:]...)
|
||||
v.additionalMajors = append(v.additionalMajors, majs...)
|
||||
return v
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import (
|
||||
"cdr.dev/slog/sloggers/slogstackdriver"
|
||||
"github.com/coder/coder/v2/agent"
|
||||
"github.com/coder/coder/v2/agent/agentproc"
|
||||
"github.com/coder/coder/v2/agent/agentssh"
|
||||
"github.com/coder/coder/v2/agent/reaper"
|
||||
"github.com/coder/coder/v2/buildinfo"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
@@ -48,6 +49,9 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
slogHumanPath string
|
||||
slogJSONPath string
|
||||
slogStackdriverPath string
|
||||
blockFileTransfer bool
|
||||
agentHeaderCommand string
|
||||
agentHeader []string
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "agent",
|
||||
@@ -174,6 +178,14 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
// with large payloads can take a bit. e.g. startup scripts
|
||||
// may take a while to insert.
|
||||
client.SDK.HTTPClient.Timeout = 30 * time.Second
|
||||
// Attach header transport so we process --agent-header and
|
||||
// --agent-header-command flags
|
||||
headerTransport, err := headerTransport(ctx, r.agentURL, agentHeader, agentHeaderCommand)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("configure header transport: %w", err)
|
||||
}
|
||||
headerTransport.Transport = client.SDK.HTTPClient.Transport
|
||||
client.SDK.HTTPClient.Transport = headerTransport
|
||||
|
||||
// Enable pprof handler
|
||||
// This prevents the pprof import from being accidentally deleted.
|
||||
@@ -314,6 +326,8 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
// Intentionally set this to nil. It's mainly used
|
||||
// for testing.
|
||||
ModifiedProcesses: nil,
|
||||
|
||||
BlockFileTransfer: blockFileTransfer,
|
||||
})
|
||||
|
||||
promHandler := agent.PrometheusMetricsHandler(prometheusRegistry, logger)
|
||||
@@ -357,6 +371,18 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
Value: serpent.StringOf(&pprofAddress),
|
||||
Description: "The address to serve pprof.",
|
||||
},
|
||||
{
|
||||
Flag: "agent-header-command",
|
||||
Env: "CODER_AGENT_HEADER_COMMAND",
|
||||
Value: serpent.StringOf(&agentHeaderCommand),
|
||||
Description: "An external command that outputs additional HTTP headers added to all requests. The command must output each header as `key=value` on its own line.",
|
||||
},
|
||||
{
|
||||
Flag: "agent-header",
|
||||
Env: "CODER_AGENT_HEADER",
|
||||
Value: serpent.StringArrayOf(&agentHeader),
|
||||
Description: "Additional HTTP headers added to all requests. Provide as " + `key=value` + ". Can be specified multiple times.",
|
||||
},
|
||||
{
|
||||
Flag: "no-reap",
|
||||
|
||||
@@ -417,6 +443,13 @@ func (r *RootCmd) workspaceAgent() *serpent.Command {
|
||||
Default: "",
|
||||
Value: serpent.StringOf(&slogStackdriverPath),
|
||||
},
|
||||
{
|
||||
Flag: "block-file-transfer",
|
||||
Default: "false",
|
||||
Env: "CODER_AGENT_BLOCK_FILE_TRANSFER",
|
||||
Description: fmt.Sprintf("Block file transfer using known applications: %s.", strings.Join(agentssh.BlockedFileTransferCommands, ",")),
|
||||
Value: serpent.BoolOf(&blockFileTransfer),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
|
||||
@@ -3,10 +3,13 @@ package cli_test
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -229,6 +232,43 @@ func TestWorkspaceAgent(t *testing.T) {
|
||||
require.Equal(t, codersdk.AgentSubsystemEnvbox, resources[0].Agents[0].Subsystems[0])
|
||||
require.Equal(t, codersdk.AgentSubsystemExectrace, resources[0].Agents[0].Subsystems[1])
|
||||
})
|
||||
t.Run("Header", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var url string
|
||||
var called int64
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
assert.Equal(t, "wow", r.Header.Get("X-Testing"))
|
||||
assert.Equal(t, "Ethan was Here!", r.Header.Get("Cool-Header"))
|
||||
assert.Equal(t, "very-wow-"+url, r.Header.Get("X-Process-Testing"))
|
||||
assert.Equal(t, "more-wow", r.Header.Get("X-Process-Testing2"))
|
||||
atomic.AddInt64(&called, 1)
|
||||
w.WriteHeader(http.StatusGone)
|
||||
}))
|
||||
defer srv.Close()
|
||||
url = srv.URL
|
||||
coderURLEnv := "$CODER_URL"
|
||||
if runtime.GOOS == "windows" {
|
||||
coderURLEnv = "%CODER_URL%"
|
||||
}
|
||||
|
||||
logDir := t.TempDir()
|
||||
inv, _ := clitest.New(t,
|
||||
"agent",
|
||||
"--auth", "token",
|
||||
"--agent-token", "fake-token",
|
||||
"--agent-url", srv.URL,
|
||||
"--log-dir", logDir,
|
||||
"--agent-header", "X-Testing=wow",
|
||||
"--agent-header", "Cool-Header=Ethan was Here!",
|
||||
"--agent-header-command", "printf X-Process-Testing=very-wow-"+coderURLEnv+"'\\r\\n'X-Process-Testing2=more-wow",
|
||||
)
|
||||
|
||||
clitest.Start(t, inv)
|
||||
require.Eventually(t, func() bool {
|
||||
return atomic.LoadInt64(&called) > 0
|
||||
}, testutil.WaitShort, testutil.IntervalFast)
|
||||
})
|
||||
}
|
||||
|
||||
func matchAgentWithVersion(rs []codersdk.WorkspaceResource) bool {
|
||||
|
||||
@@ -24,7 +24,7 @@ func TestAutoUpdate(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
require.Equal(t, codersdk.AutomaticUpdatesNever, workspace.AutomaticUpdates)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
@@ -183,11 +184,11 @@ func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) {
|
||||
IncludeProvisionerDaemon: true,
|
||||
})
|
||||
firstUser := coderdtest.CreateFirstUser(t, rootClient)
|
||||
secondUser, err := rootClient.CreateUser(ctx, codersdk.CreateUserRequest{
|
||||
Email: "testuser2@coder.com",
|
||||
Username: "testuser2",
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
OrganizationID: firstUser.OrganizationID,
|
||||
secondUser, err := rootClient.CreateUserWithOrgs(ctx, codersdk.CreateUserRequestWithOrgs{
|
||||
Email: "testuser2@coder.com",
|
||||
Username: "testuser2",
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
OrganizationIDs: []uuid.UUID{firstUser.OrganizationID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
version := coderdtest.CreateTemplateVersion(t, rootClient, firstUser.OrganizationID, nil)
|
||||
@@ -195,7 +196,7 @@ func prepareTestData(t *testing.T) (*codersdk.Client, map[string]string) {
|
||||
template := coderdtest.CreateTemplate(t, rootClient, firstUser.OrganizationID, version.ID, func(req *codersdk.CreateTemplateRequest) {
|
||||
req.Name = "test-template"
|
||||
})
|
||||
workspace := coderdtest.CreateWorkspace(t, rootClient, firstUser.OrganizationID, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
workspace := coderdtest.CreateWorkspace(t, rootClient, template.ID, func(req *codersdk.CreateWorkspaceRequest) {
|
||||
req.Name = "test-workspace"
|
||||
})
|
||||
workspaceBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, rootClient, workspace.LatestBuild.ID)
|
||||
|
||||
+131
-7
@@ -10,8 +10,11 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
)
|
||||
|
||||
@@ -22,6 +25,7 @@ type AgentOptions struct {
|
||||
Fetch func(ctx context.Context, agentID uuid.UUID) (codersdk.WorkspaceAgent, error)
|
||||
FetchLogs func(ctx context.Context, agentID uuid.UUID, after int64, follow bool) (<-chan []codersdk.WorkspaceAgentLog, io.Closer, error)
|
||||
Wait bool // If true, wait for the agent to be ready (startup script).
|
||||
DocsURL string
|
||||
}
|
||||
|
||||
// Agent displays a spinning indicator that waits for a workspace agent to connect.
|
||||
@@ -116,7 +120,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
if agent.Status == codersdk.WorkspaceAgentTimeout {
|
||||
now := time.Now()
|
||||
sw.Log(now, codersdk.LogLevelInfo, "The workspace agent is having trouble connecting, wait for it to connect or restart your workspace.")
|
||||
sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates#agent-connection-issues"))
|
||||
sw.Log(now, codersdk.LogLevelInfo, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#agent-connection-issues", opts.DocsURL)))
|
||||
for agent.Status == codersdk.WorkspaceAgentTimeout {
|
||||
if agent, err = fetch(); err != nil {
|
||||
return xerrors.Errorf("fetch: %w", err)
|
||||
@@ -132,11 +136,14 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
}
|
||||
|
||||
stage := "Running workspace agent startup scripts"
|
||||
follow := opts.Wait
|
||||
follow := opts.Wait && agent.LifecycleState.Starting()
|
||||
if !follow {
|
||||
stage += " (non-blocking)"
|
||||
}
|
||||
sw.Start(stage)
|
||||
if follow {
|
||||
sw.Log(time.Time{}, codersdk.LogLevelInfo, "==> ℹ︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.")
|
||||
}
|
||||
|
||||
err = func() error { // Use func because of defer in for loop.
|
||||
logStream, logsCloser, err := opts.FetchLogs(ctx, agent.ID, 0, follow)
|
||||
@@ -206,19 +213,25 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
case codersdk.WorkspaceAgentLifecycleReady:
|
||||
sw.Complete(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
|
||||
case codersdk.WorkspaceAgentLifecycleStartTimeout:
|
||||
sw.Fail(stage, 0)
|
||||
// Backwards compatibility: Avoid printing warning if
|
||||
// coderd is old and doesn't set ReadyAt for timeouts.
|
||||
if agent.ReadyAt == nil {
|
||||
sw.Fail(stage, 0)
|
||||
} else {
|
||||
sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
|
||||
}
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script timed out and your workspace may be incomplete.")
|
||||
case codersdk.WorkspaceAgentLifecycleStartError:
|
||||
sw.Fail(stage, safeDuration(sw, agent.ReadyAt, agent.StartedAt))
|
||||
// Use zero time (omitted) to separate these from the startup logs.
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Warning: A startup script exited with an error and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#startup-script-exited-with-an-error"))
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#startup-script-exited-with-an-error", opts.DocsURL)))
|
||||
default:
|
||||
switch {
|
||||
case agent.LifecycleState.Starting():
|
||||
// Use zero time (omitted) to separate these from the startup logs.
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, "Notice: The startup scripts are still running and your workspace may be incomplete.")
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#your-workspace-may-be-incomplete"))
|
||||
sw.Log(time.Time{}, codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#your-workspace-may-be-incomplete", opts.DocsURL)))
|
||||
// Note: We don't complete or fail the stage here, it's
|
||||
// intentionally left open to indicate this stage didn't
|
||||
// complete.
|
||||
@@ -240,7 +253,7 @@ func Agent(ctx context.Context, writer io.Writer, agentID uuid.UUID, opts AgentO
|
||||
stage := "The workspace agent lost connection"
|
||||
sw.Start(stage)
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, "Wait for it to reconnect or restart your workspace.")
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, "https://coder.com/docs/v2/latest/templates/troubleshooting#agent-connection-issues"))
|
||||
sw.Log(time.Now(), codersdk.LogLevelWarn, troubleshootingMessage(agent, fmt.Sprintf("%s/templates#agent-connection-issues", opts.DocsURL)))
|
||||
|
||||
disconnectedAt := agent.DisconnectedAt
|
||||
for agent.Status == codersdk.WorkspaceAgentDisconnected {
|
||||
@@ -297,7 +310,7 @@ func PeerDiagnostics(w io.Writer, d tailnet.PeerDiagnostics) {
|
||||
_, _ = fmt.Fprint(w, "✘ not connected to DERP\n")
|
||||
}
|
||||
if d.SentNode {
|
||||
_, _ = fmt.Fprint(w, "✔ sent local data to Coder networking coodinator\n")
|
||||
_, _ = fmt.Fprint(w, "✔ sent local data to Coder networking coordinator\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprint(w, "✘ have not sent local data to Coder networking coordinator\n")
|
||||
}
|
||||
@@ -337,3 +350,114 @@ func PeerDiagnostics(w io.Writer, d tailnet.PeerDiagnostics) {
|
||||
_, _ = fmt.Fprint(w, "✘ Wireguard is not connected\n")
|
||||
}
|
||||
}
|
||||
|
||||
type ConnDiags struct {
|
||||
ConnInfo workspacesdk.AgentConnectionInfo
|
||||
PingP2P bool
|
||||
DisableDirect bool
|
||||
LocalNetInfo *tailcfg.NetInfo
|
||||
LocalInterfaces *healthsdk.InterfacesReport
|
||||
AgentNetcheck *healthsdk.AgentNetcheckReport
|
||||
ClientIPIsAWS bool
|
||||
AgentIPIsAWS bool
|
||||
Verbose bool
|
||||
TroubleshootingURL string
|
||||
}
|
||||
|
||||
func (d ConnDiags) Write(w io.Writer) {
|
||||
_, _ = fmt.Fprintln(w, "")
|
||||
general, client, agent := d.splitDiagnostics()
|
||||
for _, msg := range general {
|
||||
_, _ = fmt.Fprintln(w, msg)
|
||||
}
|
||||
if len(general) > 0 {
|
||||
_, _ = fmt.Fprintln(w, "")
|
||||
}
|
||||
if len(client) > 0 {
|
||||
_, _ = fmt.Fprint(w, "Possible client-side issues with direct connection:\n\n")
|
||||
for _, msg := range client {
|
||||
_, _ = fmt.Fprintf(w, " - %s\n\n", msg)
|
||||
}
|
||||
}
|
||||
if len(agent) > 0 {
|
||||
_, _ = fmt.Fprint(w, "Possible agent-side issues with direct connections:\n\n")
|
||||
for _, msg := range agent {
|
||||
_, _ = fmt.Fprintf(w, " - %s\n\n", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (d ConnDiags) splitDiagnostics() (general, client, agent []string) {
|
||||
if d.AgentNetcheck != nil {
|
||||
for _, msg := range d.AgentNetcheck.Interfaces.Warnings {
|
||||
agent = append(agent, msg.Message)
|
||||
}
|
||||
if len(d.AgentNetcheck.Interfaces.Warnings) > 0 {
|
||||
agent[len(agent)-1] += fmt.Sprintf("\n%s#low-mtu", d.TroubleshootingURL)
|
||||
}
|
||||
}
|
||||
|
||||
if d.LocalInterfaces != nil {
|
||||
for _, msg := range d.LocalInterfaces.Warnings {
|
||||
client = append(client, msg.Message)
|
||||
}
|
||||
if len(d.LocalInterfaces.Warnings) > 0 {
|
||||
client[len(client)-1] += fmt.Sprintf("\n%s#low-mtu", d.TroubleshootingURL)
|
||||
}
|
||||
}
|
||||
|
||||
if d.PingP2P && !d.Verbose {
|
||||
return general, client, agent
|
||||
}
|
||||
|
||||
if d.DisableDirect {
|
||||
general = append(general, "❗ Direct connections are disabled locally, by `--disable-direct` or `CODER_DISABLE_DIRECT`")
|
||||
if !d.Verbose {
|
||||
return general, client, agent
|
||||
}
|
||||
}
|
||||
|
||||
if d.ConnInfo.DisableDirectConnections {
|
||||
general = append(general,
|
||||
fmt.Sprintf("❗ Your Coder administrator has blocked direct connections\n %s#disabled-deployment-wide", d.TroubleshootingURL))
|
||||
if !d.Verbose {
|
||||
return general, client, agent
|
||||
}
|
||||
}
|
||||
|
||||
if !d.ConnInfo.DERPMap.HasSTUN() {
|
||||
general = append(general,
|
||||
fmt.Sprintf("❗ The DERP map is not configured to use STUN\n %s#no-stun-servers", d.TroubleshootingURL))
|
||||
} else if d.LocalNetInfo != nil && !d.LocalNetInfo.UDP {
|
||||
client = append(client,
|
||||
fmt.Sprintf("Client could not connect to STUN over UDP\n %s#udp-blocked", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
if d.LocalNetInfo != nil && d.LocalNetInfo.MappingVariesByDestIP.EqualBool(true) {
|
||||
client = append(client,
|
||||
fmt.Sprintf("Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
if d.AgentNetcheck != nil && d.AgentNetcheck.NetInfo != nil {
|
||||
if d.AgentNetcheck.NetInfo.MappingVariesByDestIP.EqualBool(true) {
|
||||
agent = append(agent,
|
||||
fmt.Sprintf("Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
if !d.AgentNetcheck.NetInfo.UDP {
|
||||
agent = append(agent,
|
||||
fmt.Sprintf("Agent could not connect to STUN over UDP\n %s#udp-blocked", d.TroubleshootingURL))
|
||||
}
|
||||
}
|
||||
|
||||
if d.ClientIPIsAWS {
|
||||
client = append(client,
|
||||
fmt.Sprintf("Client IP address is within an AWS range (AWS uses hard NAT)\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
if d.AgentIPIsAWS {
|
||||
agent = append(agent,
|
||||
fmt.Sprintf("Agent IP address is within an AWS range (AWS uses hard NAT)\n %s#endpoint-dependent-nat-hard-nat", d.TroubleshootingURL))
|
||||
}
|
||||
|
||||
return general, client, agent
|
||||
}
|
||||
|
||||
+205
-3
@@ -20,8 +20,11 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/health"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/coder/v2/tailnet"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
"github.com/coder/serpent"
|
||||
@@ -95,6 +98,8 @@ func TestAgent(t *testing.T) {
|
||||
iter: []func(context.Context, *testing.T, *codersdk.WorkspaceAgent, <-chan string, chan []codersdk.WorkspaceAgentLog) error{
|
||||
func(_ context.Context, _ *testing.T, agent *codersdk.WorkspaceAgent, _ <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
agent.Status = codersdk.WorkspaceAgentConnecting
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStarting
|
||||
agent.StartedAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
},
|
||||
func(_ context.Context, t *testing.T, agent *codersdk.WorkspaceAgent, output <-chan string, _ chan []codersdk.WorkspaceAgentLog) error {
|
||||
@@ -104,6 +109,7 @@ func TestAgent(t *testing.T) {
|
||||
agent.Status = codersdk.WorkspaceAgentConnected
|
||||
agent.LifecycleState = codersdk.WorkspaceAgentLifecycleStartTimeout
|
||||
agent.FirstConnectedAt = ptr.Ref(time.Now())
|
||||
agent.ReadyAt = ptr.Ref(time.Now())
|
||||
return nil
|
||||
},
|
||||
},
|
||||
@@ -226,6 +232,7 @@ func TestAgent(t *testing.T) {
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Running workspace agent startup scripts",
|
||||
"ℹ︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.",
|
||||
"testing: Hello world",
|
||||
"Bye now",
|
||||
"✔ Running workspace agent startup scripts",
|
||||
@@ -254,9 +261,9 @@ func TestAgent(t *testing.T) {
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Running workspace agent startup scripts",
|
||||
"⧗ Running workspace agent startup scripts (non-blocking)",
|
||||
"Hello world",
|
||||
"✘ Running workspace agent startup scripts",
|
||||
"✘ Running workspace agent startup scripts (non-blocking)",
|
||||
"Warning: A startup script exited with an error and your workspace may be incomplete.",
|
||||
"For more information and troubleshooting, see",
|
||||
},
|
||||
@@ -306,6 +313,7 @@ func TestAgent(t *testing.T) {
|
||||
},
|
||||
want: []string{
|
||||
"⧗ Running workspace agent startup scripts",
|
||||
"ℹ︎ To connect immediately, reconnect with --wait=no or CODER_SSH_WAIT=no, see --help for more information.",
|
||||
"Hello world",
|
||||
"✔ Running workspace agent startup scripts",
|
||||
},
|
||||
@@ -525,7 +533,7 @@ func TestPeerDiagnostics(t *testing.T) {
|
||||
LastWireguardHandshake: time.Time{},
|
||||
},
|
||||
want: []*regexp.Regexp{
|
||||
regexp.MustCompile(`^✔ sent local data to Coder networking coodinator$`),
|
||||
regexp.MustCompile(`^✔ sent local data to Coder networking coordinator$`),
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -667,3 +675,197 @@ func TestPeerDiagnostics(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConnDiagnostics(t *testing.T) {
|
||||
t.Parallel()
|
||||
testCases := []struct {
|
||||
name string
|
||||
diags cliui.ConnDiags
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "DirectBlocked",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
DisableDirectConnections: true,
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`❗ Your Coder administrator has blocked direct connections`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "NoStun",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
LocalNetInfo: &tailcfg.NetInfo{},
|
||||
},
|
||||
want: []string{
|
||||
`The DERP map is not configured to use STUN`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClientHasStunNoUDP",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
999: {
|
||||
Nodes: []*tailcfg.DERPNode{
|
||||
{
|
||||
STUNPort: 1337,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
LocalNetInfo: &tailcfg.NetInfo{
|
||||
UDP: false,
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`Client could not connect to STUN over UDP`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AgentHasStunNoUDP",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{
|
||||
Regions: map[int]*tailcfg.DERPRegion{
|
||||
999: {
|
||||
Nodes: []*tailcfg.DERPNode{
|
||||
{
|
||||
STUNPort: 1337,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
AgentNetcheck: &healthsdk.AgentNetcheckReport{
|
||||
NetInfo: &tailcfg.NetInfo{
|
||||
UDP: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`Agent could not connect to STUN over UDP`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClientHardNat",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
LocalNetInfo: &tailcfg.NetInfo{
|
||||
MappingVariesByDestIP: "true",
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`Client is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AgentHardNat",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
LocalNetInfo: &tailcfg.NetInfo{},
|
||||
AgentNetcheck: &healthsdk.AgentNetcheckReport{
|
||||
NetInfo: &tailcfg.NetInfo{MappingVariesByDestIP: "true"},
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`Agent is potentially behind a hard NAT, as multiple endpoints were retrieved from different STUN servers`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AgentInterfaceWarnings",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
AgentNetcheck: &healthsdk.AgentNetcheckReport{
|
||||
Interfaces: healthsdk.InterfacesReport{
|
||||
BaseReport: healthsdk.BaseReport{
|
||||
Warnings: []health.Message{
|
||||
health.Messagef(health.CodeInterfaceSmallMTU, "Network interface eth0 has MTU 1280, (less than 1378), which may degrade the quality of direct connections"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`Network interface eth0 has MTU 1280, (less than 1378), which may degrade the quality of direct connections`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "LocalInterfaceWarnings",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
LocalInterfaces: &healthsdk.InterfacesReport{
|
||||
BaseReport: healthsdk.BaseReport{
|
||||
Warnings: []health.Message{
|
||||
health.Messagef(health.CodeInterfaceSmallMTU, "Network interface eth1 has MTU 1310, (less than 1378), which may degrade the quality of direct connections"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: []string{
|
||||
`Network interface eth1 has MTU 1310, (less than 1378), which may degrade the quality of direct connections`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ClientAWSIP",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
ClientIPIsAWS: true,
|
||||
AgentIPIsAWS: false,
|
||||
},
|
||||
want: []string{
|
||||
`Client IP address is within an AWS range (AWS uses hard NAT)`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "AgentAWSIP",
|
||||
diags: cliui.ConnDiags{
|
||||
ConnInfo: workspacesdk.AgentConnectionInfo{
|
||||
DERPMap: &tailcfg.DERPMap{},
|
||||
},
|
||||
ClientIPIsAWS: false,
|
||||
AgentIPIsAWS: true,
|
||||
},
|
||||
want: []string{
|
||||
`Agent IP address is within an AWS range (AWS uses hard NAT)`,
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tc := range testCases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
r, w := io.Pipe()
|
||||
go func() {
|
||||
defer w.Close()
|
||||
tc.diags.Write(w)
|
||||
}()
|
||||
bytes, err := io.ReadAll(r)
|
||||
require.NoError(t, err)
|
||||
output := string(bytes)
|
||||
for _, want := range tc.want {
|
||||
require.Contains(t, output, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+34
-22
@@ -22,6 +22,7 @@ type Styles struct {
|
||||
DateTimeStamp,
|
||||
Error,
|
||||
Field,
|
||||
Hyperlink,
|
||||
Keyword,
|
||||
Placeholder,
|
||||
Prompt,
|
||||
@@ -37,17 +38,21 @@ var (
|
||||
)
|
||||
|
||||
var (
|
||||
Green = Color("#04B575")
|
||||
Red = Color("#ED567A")
|
||||
Fuchsia = Color("#EE6FF8")
|
||||
Yellow = Color("#ECFD65")
|
||||
Blue = Color("#5000ff")
|
||||
// ANSI color codes
|
||||
red = Color("1")
|
||||
green = Color("2")
|
||||
yellow = Color("3")
|
||||
magenta = Color("5")
|
||||
white = Color("7")
|
||||
brightBlue = Color("12")
|
||||
brightMagenta = Color("13")
|
||||
)
|
||||
|
||||
// Color returns a color for the given string.
|
||||
func Color(s string) termenv.Color {
|
||||
colorOnce.Do(func() {
|
||||
color = termenv.NewOutput(os.Stdout).ColorProfile()
|
||||
color = termenv.NewOutput(os.Stdout).EnvColorProfile()
|
||||
|
||||
if flag.Lookup("test.v") != nil {
|
||||
// Use a consistent colorless profile in tests so that results
|
||||
// are deterministic.
|
||||
@@ -123,42 +128,49 @@ func init() {
|
||||
DefaultStyles = Styles{
|
||||
Code: pretty.Style{
|
||||
ifTerm(pretty.XPad(1, 1)),
|
||||
pretty.FgColor(Red),
|
||||
pretty.BgColor(color.Color("#2c2c2c")),
|
||||
pretty.FgColor(Color("#ED567A")),
|
||||
pretty.BgColor(Color("#2C2C2C")),
|
||||
},
|
||||
DateTimeStamp: pretty.Style{
|
||||
pretty.FgColor(color.Color("#7571F9")),
|
||||
pretty.FgColor(brightBlue),
|
||||
},
|
||||
Error: pretty.Style{
|
||||
pretty.FgColor(Red),
|
||||
pretty.FgColor(red),
|
||||
},
|
||||
Field: pretty.Style{
|
||||
pretty.XPad(1, 1),
|
||||
pretty.FgColor(color.Color("#FFFFFF")),
|
||||
pretty.BgColor(color.Color("#2b2a2a")),
|
||||
pretty.FgColor(Color("#FFFFFF")),
|
||||
pretty.BgColor(Color("#2B2A2A")),
|
||||
},
|
||||
Fuchsia: pretty.Style{
|
||||
pretty.FgColor(brightMagenta),
|
||||
},
|
||||
FocusedPrompt: pretty.Style{
|
||||
pretty.FgColor(white),
|
||||
pretty.Wrap("> ", ""),
|
||||
pretty.FgColor(brightBlue),
|
||||
},
|
||||
Hyperlink: pretty.Style{
|
||||
pretty.FgColor(magenta),
|
||||
pretty.Underline(),
|
||||
},
|
||||
Keyword: pretty.Style{
|
||||
pretty.FgColor(Green),
|
||||
pretty.FgColor(green),
|
||||
},
|
||||
Placeholder: pretty.Style{
|
||||
pretty.FgColor(color.Color("#4d46b3")),
|
||||
pretty.FgColor(magenta),
|
||||
},
|
||||
Prompt: pretty.Style{
|
||||
pretty.FgColor(color.Color("#5C5C5C")),
|
||||
pretty.Wrap("> ", ""),
|
||||
pretty.FgColor(white),
|
||||
pretty.Wrap(" ", ""),
|
||||
},
|
||||
Warn: pretty.Style{
|
||||
pretty.FgColor(Yellow),
|
||||
pretty.FgColor(yellow),
|
||||
},
|
||||
Wrap: pretty.Style{
|
||||
pretty.LineWrap(80),
|
||||
},
|
||||
}
|
||||
|
||||
DefaultStyles.FocusedPrompt = append(
|
||||
DefaultStyles.Prompt,
|
||||
pretty.FgColor(Blue),
|
||||
)
|
||||
}
|
||||
|
||||
// ValidateNotEmpty is a helper function to disallow empty inputs!
|
||||
|
||||
+10
-5
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/jedib0t/go-pretty/v6/table"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
@@ -64,8 +65,8 @@ func (f *OutputFormatter) AttachOptions(opts *serpent.OptionSet) {
|
||||
Flag: "output",
|
||||
FlagShorthand: "o",
|
||||
Default: f.formats[0].ID(),
|
||||
Value: serpent.StringOf(&f.formatID),
|
||||
Description: "Output format. Available formats: " + strings.Join(formatNames, ", ") + ".",
|
||||
Value: serpent.EnumOf(&f.formatID, formatNames...),
|
||||
Description: "Output format.",
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -135,15 +136,19 @@ func (f *tableFormat) AttachOptions(opts *serpent.OptionSet) {
|
||||
Flag: "column",
|
||||
FlagShorthand: "c",
|
||||
Default: strings.Join(f.defaultColumns, ","),
|
||||
Value: serpent.StringArrayOf(&f.columns),
|
||||
Description: "Columns to display in table output. Available columns: " + strings.Join(f.allColumns, ", ") + ".",
|
||||
Value: serpent.EnumArrayOf(&f.columns, f.allColumns...),
|
||||
Description: "Columns to display in table output.",
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
// Format implements OutputFormat.
|
||||
func (f *tableFormat) Format(_ context.Context, data any) (string, error) {
|
||||
return DisplayTable(data, f.sort, f.columns)
|
||||
headers := make(table.Row, len(f.allColumns))
|
||||
for i, header := range f.allColumns {
|
||||
headers[i] = header
|
||||
}
|
||||
return renderTable(data, f.sort, headers, f.columns)
|
||||
}
|
||||
|
||||
type jsonFormat struct{}
|
||||
|
||||
@@ -106,11 +106,11 @@ func Test_OutputFormatter(t *testing.T) {
|
||||
|
||||
fs := cmd.Options.FlagSet()
|
||||
|
||||
selected, err := fs.GetString("output")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "json", selected)
|
||||
selected := cmd.Options.ByFlag("output")
|
||||
require.NotNil(t, selected)
|
||||
require.Equal(t, "json", selected.Value.String())
|
||||
usage := fs.FlagUsages()
|
||||
require.Contains(t, usage, "Available formats: json, foo")
|
||||
require.Contains(t, usage, "Output format.")
|
||||
require.Contains(t, usage, "foo flag 1234")
|
||||
|
||||
ctx := context.Background()
|
||||
@@ -129,11 +129,10 @@ func Test_OutputFormatter(t *testing.T) {
|
||||
require.Equal(t, "foo", out)
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&called))
|
||||
|
||||
require.NoError(t, fs.Set("output", "bar"))
|
||||
require.Error(t, fs.Set("output", "bar"))
|
||||
out, err = f.Format(ctx, data)
|
||||
require.Error(t, err)
|
||||
require.ErrorContains(t, err, "bar")
|
||||
require.Equal(t, "", out)
|
||||
require.EqualValues(t, 1, atomic.LoadInt64(&called))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "foo", out)
|
||||
require.EqualValues(t, 2, atomic.LoadInt64(&called))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -43,7 +43,10 @@ func RichParameter(inv *serpent.Invocation, templateVersionParameter codersdk.Te
|
||||
return "", err
|
||||
}
|
||||
|
||||
values, err := MultiSelect(inv, options)
|
||||
values, err := MultiSelect(inv, MultiSelectOptions{
|
||||
Options: options,
|
||||
Defaults: options,
|
||||
})
|
||||
if err == nil {
|
||||
v, err := json.Marshal(&values)
|
||||
if err != nil {
|
||||
|
||||
+431
-83
@@ -1,61 +1,59 @@
|
||||
package cliui
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"flag"
|
||||
"io"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func init() {
|
||||
survey.SelectQuestionTemplate = `
|
||||
{{- define "option"}}
|
||||
{{- " " }}{{- if eq .SelectedIndex .CurrentIndex }}{{color "green" }}{{ .Config.Icons.SelectFocus.Text }} {{else}}{{color "default"}} {{end}}
|
||||
{{- .CurrentOpt.Value}}
|
||||
{{- color "reset"}}
|
||||
{{end}}
|
||||
const defaultSelectModelHeight = 7
|
||||
|
||||
{{- if not .ShowAnswer }}
|
||||
{{- if .Config.Icons.Help.Text }}
|
||||
{{- if .FilterMessage }}{{ "Search:" }}{{ .FilterMessage }}
|
||||
{{- else }}
|
||||
{{- color "black+h"}}{{- "Type to search" }}{{color "reset"}}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- end }}
|
||||
{{- "\n" }}
|
||||
{{- range $ix, $option := .PageEntries}}
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end }}`
|
||||
type terminateMsg struct{}
|
||||
|
||||
survey.MultiSelectQuestionTemplate = `
|
||||
{{- define "option"}}
|
||||
{{- if eq .SelectedIndex .CurrentIndex }}{{color .Config.Icons.SelectFocus.Format }}{{ .Config.Icons.SelectFocus.Text }}{{color "reset"}}{{else}} {{end}}
|
||||
{{- if index .Checked .CurrentOpt.Index }}{{color .Config.Icons.MarkedOption.Format }} {{ .Config.Icons.MarkedOption.Text }} {{else}}{{color .Config.Icons.UnmarkedOption.Format }} {{ .Config.Icons.UnmarkedOption.Text }} {{end}}
|
||||
{{- color "reset"}}
|
||||
{{- " "}}{{- .CurrentOpt.Value}}
|
||||
{{end}}
|
||||
{{- if .ShowHelp }}{{- color .Config.Icons.Help.Format }}{{ .Config.Icons.Help.Text }} {{ .Help }}{{color "reset"}}{{"\n"}}{{end}}
|
||||
{{- if not .ShowAnswer }}
|
||||
{{- "\n"}}
|
||||
{{- range $ix, $option := .PageEntries}}
|
||||
{{- template "option" $.IterateOption $ix $option}}
|
||||
{{- end}}
|
||||
{{- end}}`
|
||||
func installSignalHandler(p *tea.Program) func() {
|
||||
ch := make(chan struct{})
|
||||
|
||||
go func() {
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt, syscall.SIGTERM)
|
||||
|
||||
defer func() {
|
||||
signal.Stop(sig)
|
||||
close(ch)
|
||||
}()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ch:
|
||||
return
|
||||
|
||||
case <-sig:
|
||||
p.Send(terminateMsg{})
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return func() {
|
||||
ch <- struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
type SelectOptions struct {
|
||||
Options []string
|
||||
// Default will be highlighted first if it's a valid option.
|
||||
Default string
|
||||
Message string
|
||||
Size int
|
||||
HideSearch bool
|
||||
}
|
||||
@@ -112,66 +110,416 @@ func Select(inv *serpent.Invocation, opts SelectOptions) (string, error) {
|
||||
return opts.Options[0], nil
|
||||
}
|
||||
|
||||
var defaultOption interface{}
|
||||
if opts.Default != "" {
|
||||
defaultOption = opts.Default
|
||||
initialModel := selectModel{
|
||||
search: textinput.New(),
|
||||
hideSearch: opts.HideSearch,
|
||||
options: opts.Options,
|
||||
height: opts.Size,
|
||||
message: opts.Message,
|
||||
}
|
||||
|
||||
var value string
|
||||
err := survey.AskOne(&survey.Select{
|
||||
Options: opts.Options,
|
||||
Default: defaultOption,
|
||||
PageSize: opts.Size,
|
||||
}, &value, survey.WithIcons(func(is *survey.IconSet) {
|
||||
is.Help.Text = "Type to search"
|
||||
if opts.HideSearch {
|
||||
is.Help.Text = ""
|
||||
}
|
||||
}), survey.WithStdio(fileReadWriter{
|
||||
Reader: inv.Stdin,
|
||||
}, fileReadWriter{
|
||||
Writer: inv.Stdout,
|
||||
}, inv.Stdout))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
return value, Canceled
|
||||
if initialModel.height == 0 {
|
||||
initialModel.height = defaultSelectModelHeight
|
||||
}
|
||||
return value, err
|
||||
|
||||
initialModel.search.Prompt = ""
|
||||
initialModel.search.Focus()
|
||||
|
||||
p := tea.NewProgram(
|
||||
initialModel,
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(inv.Context()),
|
||||
tea.WithInput(inv.Stdin),
|
||||
tea.WithOutput(inv.Stdout),
|
||||
)
|
||||
|
||||
closeSignalHandler := installSignalHandler(p)
|
||||
defer closeSignalHandler()
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
model, ok := m.(selectModel)
|
||||
if !ok {
|
||||
return "", xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m))
|
||||
}
|
||||
|
||||
if model.canceled {
|
||||
return "", Canceled
|
||||
}
|
||||
|
||||
return model.selected, nil
|
||||
}
|
||||
|
||||
func MultiSelect(inv *serpent.Invocation, items []string) ([]string, error) {
|
||||
type selectModel struct {
|
||||
search textinput.Model
|
||||
options []string
|
||||
cursor int
|
||||
height int
|
||||
message string
|
||||
selected string
|
||||
canceled bool
|
||||
hideSearch bool
|
||||
}
|
||||
|
||||
func (selectModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:revive // The linter complains about modifying 'm' but this is typical practice for bubbletea
|
||||
func (m selectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case terminateMsg:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
options := m.filteredOptions()
|
||||
if len(options) != 0 {
|
||||
m.selected = options[m.cursor]
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case tea.KeyUp:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
} else {
|
||||
m.cursor = len(options) - 1
|
||||
}
|
||||
|
||||
case tea.KeyDown:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor < len(options)-1 {
|
||||
m.cursor++
|
||||
} else {
|
||||
m.cursor = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !m.hideSearch {
|
||||
oldSearch := m.search.Value()
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
|
||||
// If the search query has changed then we need to ensure
|
||||
// the cursor is still pointing at a valid option.
|
||||
if m.search.Value() != oldSearch {
|
||||
options := m.filteredOptions()
|
||||
|
||||
if m.cursor > len(options)-1 {
|
||||
m.cursor = max(0, len(options)-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m selectModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message)
|
||||
|
||||
if m.selected != "" {
|
||||
selected := pretty.Sprint(DefaultStyles.Keyword, m.selected)
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
if m.hideSearch {
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s [Use arrows to move]\n", msg))
|
||||
} else {
|
||||
_, _ = s.WriteString(fmt.Sprintf(
|
||||
"%s %s[Use arrows to move, type to filter]\n",
|
||||
msg,
|
||||
m.search.View(),
|
||||
))
|
||||
}
|
||||
|
||||
options, start := m.viewableOptions()
|
||||
|
||||
for i, option := range options {
|
||||
// Is this the currently selected option?
|
||||
style := pretty.Wrap(" ", "")
|
||||
if m.cursor == start+i {
|
||||
style = pretty.Style{
|
||||
pretty.Wrap("> ", ""),
|
||||
DefaultStyles.Keyword,
|
||||
}
|
||||
}
|
||||
|
||||
_, _ = s.WriteString(pretty.Sprint(style, option))
|
||||
_, _ = s.WriteString("\n")
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m selectModel) viewableOptions() ([]string, int) {
|
||||
options := m.filteredOptions()
|
||||
halfHeight := m.height / 2
|
||||
bottom := 0
|
||||
top := len(options)
|
||||
|
||||
switch {
|
||||
case m.cursor <= halfHeight:
|
||||
top = min(top, m.height)
|
||||
case m.cursor < top-halfHeight:
|
||||
bottom = max(0, m.cursor-halfHeight)
|
||||
top = min(top, m.cursor+halfHeight+1)
|
||||
default:
|
||||
bottom = max(0, top-m.height)
|
||||
}
|
||||
|
||||
return options[bottom:top], bottom
|
||||
}
|
||||
|
||||
func (m selectModel) filteredOptions() []string {
|
||||
options := []string{}
|
||||
for _, o := range m.options {
|
||||
filter := strings.ToLower(m.search.Value())
|
||||
option := strings.ToLower(o)
|
||||
|
||||
if strings.Contains(option, filter) {
|
||||
options = append(options, o)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
type MultiSelectOptions struct {
|
||||
Message string
|
||||
Options []string
|
||||
Defaults []string
|
||||
}
|
||||
|
||||
func MultiSelect(inv *serpent.Invocation, opts MultiSelectOptions) ([]string, error) {
|
||||
// Similar hack is applied to Select()
|
||||
if flag.Lookup("test.v") != nil {
|
||||
return items, nil
|
||||
return opts.Defaults, nil
|
||||
}
|
||||
|
||||
prompt := &survey.MultiSelect{
|
||||
Options: items,
|
||||
Default: items,
|
||||
options := make([]*multiSelectOption, len(opts.Options))
|
||||
for i, option := range opts.Options {
|
||||
chosen := false
|
||||
for _, d := range opts.Defaults {
|
||||
if option == d {
|
||||
chosen = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
options[i] = &multiSelectOption{
|
||||
option: option,
|
||||
chosen: chosen,
|
||||
}
|
||||
}
|
||||
|
||||
var values []string
|
||||
err := survey.AskOne(prompt, &values, survey.WithStdio(fileReadWriter{
|
||||
Reader: inv.Stdin,
|
||||
}, fileReadWriter{
|
||||
Writer: inv.Stdout,
|
||||
}, inv.Stdout))
|
||||
if errors.Is(err, terminal.InterruptErr) {
|
||||
initialModel := multiSelectModel{
|
||||
search: textinput.New(),
|
||||
options: options,
|
||||
message: opts.Message,
|
||||
}
|
||||
|
||||
initialModel.search.Prompt = ""
|
||||
initialModel.search.Focus()
|
||||
|
||||
p := tea.NewProgram(
|
||||
initialModel,
|
||||
tea.WithoutSignalHandler(),
|
||||
tea.WithContext(inv.Context()),
|
||||
tea.WithInput(inv.Stdin),
|
||||
tea.WithOutput(inv.Stdout),
|
||||
)
|
||||
|
||||
closeSignalHandler := installSignalHandler(p)
|
||||
defer closeSignalHandler()
|
||||
|
||||
m, err := p.Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model, ok := m.(multiSelectModel)
|
||||
if !ok {
|
||||
return nil, xerrors.New(fmt.Sprintf("unknown model found %T (%+v)", m, m))
|
||||
}
|
||||
|
||||
if model.canceled {
|
||||
return nil, Canceled
|
||||
}
|
||||
return values, err
|
||||
|
||||
return model.selectedOptions(), nil
|
||||
}
|
||||
|
||||
type fileReadWriter struct {
|
||||
io.Reader
|
||||
io.Writer
|
||||
type multiSelectOption struct {
|
||||
option string
|
||||
chosen bool
|
||||
}
|
||||
|
||||
func (f fileReadWriter) Fd() uintptr {
|
||||
if file, ok := f.Reader.(*os.File); ok {
|
||||
return file.Fd()
|
||||
}
|
||||
if file, ok := f.Writer.(*os.File); ok {
|
||||
return file.Fd()
|
||||
}
|
||||
return 0
|
||||
type multiSelectModel struct {
|
||||
search textinput.Model
|
||||
options []*multiSelectOption
|
||||
cursor int
|
||||
message string
|
||||
canceled bool
|
||||
selected bool
|
||||
}
|
||||
|
||||
func (multiSelectModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
//nolint:revive // For same reason as previous Update definition
|
||||
func (m multiSelectModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
var cmd tea.Cmd
|
||||
|
||||
switch msg := msg.(type) {
|
||||
case terminateMsg:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyCtrlC:
|
||||
m.canceled = true
|
||||
return m, tea.Quit
|
||||
|
||||
case tea.KeyEnter:
|
||||
if len(m.options) != 0 {
|
||||
m.selected = true
|
||||
return m, tea.Quit
|
||||
}
|
||||
|
||||
case tea.KeySpace:
|
||||
options := m.filteredOptions()
|
||||
if len(options) != 0 {
|
||||
options[m.cursor].chosen = !options[m.cursor].chosen
|
||||
}
|
||||
// We back out early here otherwise a space will be inserted
|
||||
// into the search field.
|
||||
return m, nil
|
||||
|
||||
case tea.KeyUp:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor > 0 {
|
||||
m.cursor--
|
||||
} else {
|
||||
m.cursor = len(options) - 1
|
||||
}
|
||||
|
||||
case tea.KeyDown:
|
||||
options := m.filteredOptions()
|
||||
if m.cursor < len(options)-1 {
|
||||
m.cursor++
|
||||
} else {
|
||||
m.cursor = 0
|
||||
}
|
||||
|
||||
case tea.KeyRight:
|
||||
options := m.filteredOptions()
|
||||
for _, option := range options {
|
||||
option.chosen = true
|
||||
}
|
||||
|
||||
case tea.KeyLeft:
|
||||
options := m.filteredOptions()
|
||||
for _, option := range options {
|
||||
option.chosen = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
oldSearch := m.search.Value()
|
||||
m.search, cmd = m.search.Update(msg)
|
||||
|
||||
// If the search query has changed then we need to ensure
|
||||
// the cursor is still pointing at a valid option.
|
||||
if m.search.Value() != oldSearch {
|
||||
options := m.filteredOptions()
|
||||
if m.cursor > len(options)-1 {
|
||||
m.cursor = max(0, len(options)-1)
|
||||
}
|
||||
}
|
||||
|
||||
return m, cmd
|
||||
}
|
||||
|
||||
func (m multiSelectModel) View() string {
|
||||
var s strings.Builder
|
||||
|
||||
msg := pretty.Sprintf(pretty.Bold(), "? %s", m.message)
|
||||
|
||||
if m.selected {
|
||||
selected := pretty.Sprint(DefaultStyles.Keyword, strings.Join(m.selectedOptions(), ", "))
|
||||
_, _ = s.WriteString(fmt.Sprintf("%s %s\n", msg, selected))
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
_, _ = s.WriteString(fmt.Sprintf(
|
||||
"%s %s[Use arrows to move, space to select, <right> to all, <left> to none, type to filter]\n",
|
||||
msg,
|
||||
m.search.View(),
|
||||
))
|
||||
|
||||
for i, option := range m.filteredOptions() {
|
||||
cursor := " "
|
||||
chosen := "[ ]"
|
||||
o := option.option
|
||||
|
||||
if m.cursor == i {
|
||||
cursor = pretty.Sprint(DefaultStyles.Keyword, "> ")
|
||||
chosen = pretty.Sprint(DefaultStyles.Keyword, "[ ]")
|
||||
o = pretty.Sprint(DefaultStyles.Keyword, o)
|
||||
}
|
||||
|
||||
if option.chosen {
|
||||
chosen = pretty.Sprint(DefaultStyles.Keyword, "[x]")
|
||||
}
|
||||
|
||||
_, _ = s.WriteString(fmt.Sprintf(
|
||||
"%s%s %s\n",
|
||||
cursor,
|
||||
chosen,
|
||||
o,
|
||||
))
|
||||
}
|
||||
|
||||
return s.String()
|
||||
}
|
||||
|
||||
func (m multiSelectModel) filteredOptions() []*multiSelectOption {
|
||||
options := []*multiSelectOption{}
|
||||
for _, o := range m.options {
|
||||
filter := strings.ToLower(m.search.Value())
|
||||
option := strings.ToLower(o.option)
|
||||
|
||||
if strings.Contains(option, filter) {
|
||||
options = append(options, o)
|
||||
}
|
||||
}
|
||||
return options
|
||||
}
|
||||
|
||||
func (m multiSelectModel) selectedOptions() []string {
|
||||
selected := []string{}
|
||||
for _, o := range m.options {
|
||||
if o.chosen {
|
||||
selected = append(selected, o.option)
|
||||
}
|
||||
}
|
||||
return selected
|
||||
}
|
||||
|
||||
@@ -107,7 +107,10 @@ func newMultiSelect(ptty *ptytest.PTY, items []string) ([]string, error) {
|
||||
var values []string
|
||||
cmd := &serpent.Command{
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
selectedItems, err := cliui.MultiSelect(inv, items)
|
||||
selectedItems, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Options: items,
|
||||
Defaults: items,
|
||||
})
|
||||
if err == nil {
|
||||
values = selectedItems
|
||||
}
|
||||
|
||||
+78
-18
@@ -22,6 +22,13 @@ func Table() table.Writer {
|
||||
return tableWriter
|
||||
}
|
||||
|
||||
// This type can be supplied as part of a slice to DisplayTable
|
||||
// or to a `TableFormat` `Format` call to render a separator.
|
||||
// Leading separators are not supported and trailing separators
|
||||
// are ignored by the table formatter.
|
||||
// e.g. `[]any{someRow, TableSeparator, someRow}`
|
||||
type TableSeparator struct{}
|
||||
|
||||
// filterTableColumns returns configurations to hide columns
|
||||
// that are not provided in the array. If the array is empty,
|
||||
// no filtering will occur!
|
||||
@@ -47,8 +54,12 @@ func filterTableColumns(header table.Row, columns []string) []table.ColumnConfig
|
||||
return columnConfigs
|
||||
}
|
||||
|
||||
// DisplayTable renders a table as a string. The input argument must be a slice
|
||||
// of structs. At least one field in the struct must have a `table:""` tag
|
||||
// DisplayTable renders a table as a string. The input argument can be:
|
||||
// - a struct slice.
|
||||
// - an interface slice, where the first element is a struct,
|
||||
// and all other elements are of the same type, or a TableSeparator.
|
||||
//
|
||||
// At least one field in the struct must have a `table:""` tag
|
||||
// containing the name of the column in the outputted table.
|
||||
//
|
||||
// If `sort` is not specified, the field with the `table:"$NAME,default_sort"`
|
||||
@@ -66,11 +77,20 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
if v.Kind() != reflect.Slice {
|
||||
return "", xerrors.Errorf("DisplayTable called with a non-slice type")
|
||||
return "", xerrors.New("DisplayTable called with a non-slice type")
|
||||
}
|
||||
var tableType reflect.Type
|
||||
if v.Type().Elem().Kind() == reflect.Interface {
|
||||
if v.Len() == 0 {
|
||||
return "", xerrors.New("DisplayTable called with empty interface slice")
|
||||
}
|
||||
tableType = reflect.Indirect(reflect.ValueOf(v.Index(0).Interface())).Type()
|
||||
} else {
|
||||
tableType = v.Type().Elem()
|
||||
}
|
||||
|
||||
// Get the list of table column headers.
|
||||
headersRaw, defaultSort, err := typeToTableHeaders(v.Type().Elem(), true)
|
||||
headersRaw, defaultSort, err := typeToTableHeaders(tableType, true)
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table headers recursively for type %q: %w", v.Type().Elem().String(), err)
|
||||
}
|
||||
@@ -82,9 +102,8 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
}
|
||||
headers := make(table.Row, len(headersRaw))
|
||||
for i, header := range headersRaw {
|
||||
headers[i] = header
|
||||
headers[i] = strings.ReplaceAll(header, "_", " ")
|
||||
}
|
||||
|
||||
// Verify that the given sort column and filter columns are valid.
|
||||
if sort != "" || len(filterColumns) != 0 {
|
||||
headersMap := make(map[string]string, len(headersRaw))
|
||||
@@ -130,6 +149,11 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
return "", xerrors.Errorf("specified sort column %q not found in table headers, available columns are %q", sort, strings.Join(headersRaw, `", "`))
|
||||
}
|
||||
}
|
||||
return renderTable(out, sort, headers, filterColumns)
|
||||
}
|
||||
|
||||
func renderTable(out any, sort string, headers table.Row, filterColumns []string) (string, error) {
|
||||
v := reflect.Indirect(reflect.ValueOf(out))
|
||||
|
||||
// Setup the table formatter.
|
||||
tw := Table()
|
||||
@@ -143,15 +167,22 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
|
||||
// Write each struct to the table.
|
||||
for i := 0; i < v.Len(); i++ {
|
||||
cur := v.Index(i).Interface()
|
||||
_, ok := cur.(TableSeparator)
|
||||
if ok {
|
||||
tw.AppendSeparator()
|
||||
continue
|
||||
}
|
||||
// Format the row as a slice.
|
||||
rowMap, err := valueToTableMap(v.Index(i))
|
||||
// ValueToTableMap does what `reflect.Indirect` does
|
||||
rowMap, err := valueToTableMap(reflect.ValueOf(cur))
|
||||
if err != nil {
|
||||
return "", xerrors.Errorf("get table row map %v: %w", i, err)
|
||||
}
|
||||
|
||||
rowSlice := make([]any, len(headers))
|
||||
for i, h := range headersRaw {
|
||||
v, ok := rowMap[h]
|
||||
for i, h := range headers {
|
||||
v, ok := rowMap[h.(string)]
|
||||
if !ok {
|
||||
v = nil
|
||||
}
|
||||
@@ -168,12 +199,34 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
if val != nil {
|
||||
v = *val
|
||||
}
|
||||
case *time.Duration:
|
||||
if val != nil {
|
||||
v = val.String()
|
||||
}
|
||||
case fmt.Stringer:
|
||||
if val != nil {
|
||||
v = val.String()
|
||||
}
|
||||
}
|
||||
|
||||
// Guard against nil dereferences
|
||||
if v != nil {
|
||||
rt := reflect.TypeOf(v)
|
||||
switch rt.Kind() {
|
||||
case reflect.Slice:
|
||||
// By default, the behavior is '%v', which just returns a string like
|
||||
// '[a b c]'. This will add commas in between each value.
|
||||
strs := make([]string, 0)
|
||||
vt := reflect.ValueOf(v)
|
||||
for i := 0; i < vt.Len(); i++ {
|
||||
strs = append(strs, fmt.Sprintf("%v", vt.Index(i).Interface()))
|
||||
}
|
||||
v = "[" + strings.Join(strs, ", ") + "]"
|
||||
default:
|
||||
// Leave it as it is
|
||||
}
|
||||
}
|
||||
|
||||
rowSlice[i] = v
|
||||
}
|
||||
|
||||
@@ -188,25 +241,28 @@ func DisplayTable(out any, sort string, filterColumns []string) (string, error)
|
||||
// returned. If the table tag is malformed, an error is returned.
|
||||
//
|
||||
// The returned name is transformed from "snake_case" to "normal text".
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, recursive bool, skipParentName bool, err error) {
|
||||
func parseTableStructTag(field reflect.StructField) (name string, defaultSort, noSortOpt, recursive, skipParentName bool, err error) {
|
||||
tags, err := structtag.Parse(string(field.Tag))
|
||||
if err != nil {
|
||||
return "", false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
return "", false, false, false, false, xerrors.Errorf("parse struct field tag %q: %w", string(field.Tag), err)
|
||||
}
|
||||
|
||||
tag, err := tags.Get("table")
|
||||
if err != nil || tag.Name == "-" {
|
||||
// tags.Get only returns an error if the tag is not found.
|
||||
return "", false, false, false, nil
|
||||
return "", false, false, false, false, nil
|
||||
}
|
||||
|
||||
defaultSortOpt := false
|
||||
noSortOpt = false
|
||||
recursiveOpt := false
|
||||
skipParentNameOpt := false
|
||||
for _, opt := range tag.Options {
|
||||
switch opt {
|
||||
case "default_sort":
|
||||
defaultSortOpt = true
|
||||
case "nosort":
|
||||
noSortOpt = true
|
||||
case "recursive":
|
||||
recursiveOpt = true
|
||||
case "recursive_inline":
|
||||
@@ -216,11 +272,11 @@ func parseTableStructTag(field reflect.StructField) (name string, defaultSort, r
|
||||
recursiveOpt = true
|
||||
skipParentNameOpt = true
|
||||
default:
|
||||
return "", false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
return "", false, false, false, false, xerrors.Errorf("unknown option %q in struct field tag", opt)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
return strings.ReplaceAll(tag.Name, "_", " "), defaultSortOpt, noSortOpt, recursiveOpt, skipParentNameOpt, nil
|
||||
}
|
||||
|
||||
func isStructOrStructPointer(t reflect.Type) bool {
|
||||
@@ -244,12 +300,16 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
|
||||
headers := []string{}
|
||||
defaultSortName := ""
|
||||
noSortOpt := false
|
||||
for i := 0; i < t.NumField(); i++ {
|
||||
field := t.Field(i)
|
||||
name, defaultSort, recursive, skip, err := parseTableStructTag(field)
|
||||
name, defaultSort, noSort, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, "", xerrors.Errorf("parse struct tags for field %q in type %q: %w", field.Name, t.String(), err)
|
||||
}
|
||||
if requireDefault && noSort {
|
||||
noSortOpt = true
|
||||
}
|
||||
|
||||
if name == "" && (recursive && skip) {
|
||||
return nil, "", xerrors.Errorf("a name is required for the field %q. "+
|
||||
@@ -292,8 +352,8 @@ func typeToTableHeaders(t reflect.Type, requireDefault bool) ([]string, string,
|
||||
headers = append(headers, name)
|
||||
}
|
||||
|
||||
if defaultSortName == "" && requireDefault {
|
||||
return nil, "", xerrors.Errorf("no field marked as default_sort in type %q", t.String())
|
||||
if defaultSortName == "" && requireDefault && !noSortOpt {
|
||||
return nil, "", xerrors.Errorf("no field marked as default_sort or nosort in type %q", t.String())
|
||||
}
|
||||
|
||||
return headers, defaultSortName, nil
|
||||
@@ -320,7 +380,7 @@ func valueToTableMap(val reflect.Value) (map[string]any, error) {
|
||||
for i := 0; i < val.NumField(); i++ {
|
||||
field := val.Type().Field(i)
|
||||
fieldVal := val.Field(i)
|
||||
name, _, recursive, skip, err := parseTableStructTag(field)
|
||||
name, _, _, recursive, skip, err := parseTableStructTag(field)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse struct tags for field %q in type %T: %w", field.Name, val, err)
|
||||
}
|
||||
|
||||
+44
-16
@@ -138,10 +138,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
|
||||
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
|
||||
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
|
||||
`
|
||||
|
||||
// Test with non-pointer values.
|
||||
@@ -165,10 +165,10 @@ foo 10 [a b c] foo1 11 foo2 12 foo3
|
||||
t.Parallel()
|
||||
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
foo 10 [a b c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
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>
|
||||
`
|
||||
|
||||
out, err := cliui.DisplayTable(in, "age", nil)
|
||||
@@ -218,6 +218,42 @@ Alice 25
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
// This test ensures we can display dynamically typed slices
|
||||
t.Run("Interfaces", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []any{tableTest1{}}
|
||||
out, err := cliui.DisplayTable(in, "", nil)
|
||||
t.Log("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
other := []tableTest1{{}}
|
||||
expected, err := cliui.DisplayTable(other, "", nil)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
t.Run("WithSeparator", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
expected := `
|
||||
NAME AGE ROLES SUB 1 NAME SUB 1 AGE SUB 2 NAME SUB 2 AGE SUB 3 INNER NAME SUB 3 INNER AGE SUB 4 TIME TIME PTR
|
||||
bar 20 [a] bar1 21 <nil> <nil> bar3 23 {bar4 24 } 2022-08-02T15:49:10Z <nil>
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
baz 30 [] baz1 31 <nil> <nil> baz3 33 {baz4 34 } 2022-08-02T15:49:10Z <nil>
|
||||
---------------------------------------------------------------------------------------------------------------------------------------------------------------
|
||||
foo 10 [a, b, c] foo1 11 foo2 12 foo3 13 {foo4 14 } 2022-08-02T15:49:10Z 2022-08-02T15:49:10Z
|
||||
`
|
||||
|
||||
var inlineIn []any
|
||||
for _, v := range in {
|
||||
inlineIn = append(inlineIn, v)
|
||||
inlineIn = append(inlineIn, cliui.TableSeparator{})
|
||||
}
|
||||
out, err := cliui.DisplayTable(inlineIn, "", nil)
|
||||
t.Log("rendered table:\n" + out)
|
||||
require.NoError(t, err)
|
||||
compareTables(t, expected, out)
|
||||
})
|
||||
|
||||
// This test ensures that safeties against invalid use of `table` tags
|
||||
// causes errors (even without data).
|
||||
t.Run("Errors", func(t *testing.T) {
|
||||
@@ -255,14 +291,6 @@ Alice 25
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
|
||||
t.Run("WithData", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
in := []any{tableTest1{}}
|
||||
_, err := cliui.DisplayTable(in, "", nil)
|
||||
require.Error(t, err)
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("NotStruct", func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
)
|
||||
|
||||
const AWSIPRangesURL = "https://ip-ranges.amazonaws.com/ip-ranges.json"
|
||||
|
||||
type awsIPv4Prefix struct {
|
||||
Prefix string `json:"ip_prefix"`
|
||||
Region string `json:"region"`
|
||||
Service string `json:"service"`
|
||||
NetworkBorderGroup string `json:"network_border_group"`
|
||||
}
|
||||
|
||||
type awsIPv6Prefix struct {
|
||||
Prefix string `json:"ipv6_prefix"`
|
||||
Region string `json:"region"`
|
||||
Service string `json:"service"`
|
||||
NetworkBorderGroup string `json:"network_border_group"`
|
||||
}
|
||||
|
||||
type AWSIPRanges struct {
|
||||
V4 []netip.Prefix
|
||||
V6 []netip.Prefix
|
||||
}
|
||||
|
||||
type awsIPRangesResponse struct {
|
||||
SyncToken string `json:"syncToken"`
|
||||
CreateDate string `json:"createDate"`
|
||||
IPV4Prefixes []awsIPv4Prefix `json:"prefixes"`
|
||||
IPV6Prefixes []awsIPv6Prefix `json:"ipv6_prefixes"`
|
||||
}
|
||||
|
||||
func FetchAWSIPRanges(ctx context.Context, url string) (*AWSIPRanges, error) {
|
||||
client := &http.Client{}
|
||||
reqCtx, reqCancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer reqCancel()
|
||||
req, _ := http.NewRequestWithContext(reqCtx, http.MethodGet, url, nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
return nil, xerrors.Errorf("unexpected status code %d: %s", resp.StatusCode, b)
|
||||
}
|
||||
|
||||
var body awsIPRangesResponse
|
||||
err = json.NewDecoder(resp.Body).Decode(&body)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("json decode: %w", err)
|
||||
}
|
||||
|
||||
out := &AWSIPRanges{
|
||||
V4: make([]netip.Prefix, 0, len(body.IPV4Prefixes)),
|
||||
V6: make([]netip.Prefix, 0, len(body.IPV6Prefixes)),
|
||||
}
|
||||
|
||||
for _, p := range body.IPV4Prefixes {
|
||||
prefix, err := netip.ParsePrefix(p.Prefix)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse ip prefix: %w", err)
|
||||
}
|
||||
if prefix.Addr().Is6() {
|
||||
return nil, xerrors.Errorf("ipv4 prefix contains ipv6 address: %s", p.Prefix)
|
||||
}
|
||||
out.V4 = append(out.V4, prefix)
|
||||
}
|
||||
|
||||
for _, p := range body.IPV6Prefixes {
|
||||
prefix, err := netip.ParsePrefix(p.Prefix)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("parse ip prefix: %w", err)
|
||||
}
|
||||
if prefix.Addr().Is4() {
|
||||
return nil, xerrors.Errorf("ipv6 prefix contains ipv4 address: %s", p.Prefix)
|
||||
}
|
||||
out.V6 = append(out.V6, prefix)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// CheckIP checks if the given IP address is an AWS IP.
|
||||
func (r *AWSIPRanges) CheckIP(ip netip.Addr) bool {
|
||||
if ip.IsLoopback() || ip.IsLinkLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsPrivate() {
|
||||
return false
|
||||
}
|
||||
|
||||
if ip.Is4() {
|
||||
for _, p := range r.V4 {
|
||||
if p.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
for _, p := range r.V6 {
|
||||
if p.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package cliutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/httpapi"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestIPV4Check(t *testing.T) {
|
||||
t.Parallel()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
httpapi.Write(context.Background(), w, http.StatusOK, awsIPRangesResponse{
|
||||
IPV4Prefixes: []awsIPv4Prefix{
|
||||
{
|
||||
Prefix: "3.24.0.0/14",
|
||||
},
|
||||
{
|
||||
Prefix: "15.230.15.29/32",
|
||||
},
|
||||
{
|
||||
Prefix: "47.128.82.100/31",
|
||||
},
|
||||
},
|
||||
IPV6Prefixes: []awsIPv6Prefix{
|
||||
{
|
||||
Prefix: "2600:9000:5206::/48",
|
||||
},
|
||||
{
|
||||
Prefix: "2406:da70:8800::/40",
|
||||
},
|
||||
{
|
||||
Prefix: "2600:1f68:5000::/40",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
ranges, err := FetchAWSIPRanges(ctx, srv.URL)
|
||||
require.NoError(t, err)
|
||||
|
||||
t.Run("Private/IPV4", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := netip.ParseAddr("192.168.0.1")
|
||||
require.NoError(t, err)
|
||||
isAws := ranges.CheckIP(ip)
|
||||
require.False(t, isAws)
|
||||
})
|
||||
|
||||
t.Run("AWS/IPV4", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := netip.ParseAddr("3.25.61.113")
|
||||
require.NoError(t, err)
|
||||
isAws := ranges.CheckIP(ip)
|
||||
require.True(t, isAws)
|
||||
})
|
||||
|
||||
t.Run("NonAWS/IPV4", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := netip.ParseAddr("159.196.123.40")
|
||||
require.NoError(t, err)
|
||||
isAws := ranges.CheckIP(ip)
|
||||
require.False(t, isAws)
|
||||
})
|
||||
|
||||
t.Run("Private/IPV6", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := netip.ParseAddr("::1")
|
||||
require.NoError(t, err)
|
||||
isAws := ranges.CheckIP(ip)
|
||||
require.False(t, isAws)
|
||||
})
|
||||
|
||||
t.Run("AWS/IPV6", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := netip.ParseAddr("2600:9000:5206:0001:0000:0000:0000:0001")
|
||||
require.NoError(t, err)
|
||||
isAws := ranges.CheckIP(ip)
|
||||
require.True(t, isAws)
|
||||
})
|
||||
|
||||
t.Run("NonAWS/IPV6", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ip, err := netip.ParseAddr("2403:5807:885f:0:a544:49d4:58f8:aedf")
|
||||
require.NoError(t, err)
|
||||
isAws := ranges.CheckIP(ip)
|
||||
require.False(t, isAws)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/serpent"
|
||||
"github.com/coder/serpent/completion"
|
||||
)
|
||||
|
||||
func (*RootCmd) completion() *serpent.Command {
|
||||
var shellName string
|
||||
var printOutput bool
|
||||
shellOptions := completion.ShellOptions(&shellName)
|
||||
return &serpent.Command{
|
||||
Use: "completion",
|
||||
Short: "Install or update shell completion scripts for the detected or chosen shell.",
|
||||
Options: []serpent.Option{
|
||||
{
|
||||
Flag: "shell",
|
||||
FlagShorthand: "s",
|
||||
Description: "The shell to install completion for.",
|
||||
Value: shellOptions,
|
||||
},
|
||||
{
|
||||
Flag: "print",
|
||||
Description: "Print the completion script instead of installing it.",
|
||||
FlagShorthand: "p",
|
||||
|
||||
Value: serpent.BoolOf(&printOutput),
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
if shellName != "" {
|
||||
shell, err := completion.ShellByName(shellName, inv.Command.Parent.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if printOutput {
|
||||
return shell.WriteCompletion(inv.Stdout)
|
||||
}
|
||||
return installCompletion(inv, shell)
|
||||
}
|
||||
shell, err := completion.DetectUserShell(inv.Command.Parent.Name())
|
||||
if err == nil {
|
||||
return installCompletion(inv, shell)
|
||||
}
|
||||
if !isTTYOut(inv) {
|
||||
return xerrors.New("could not detect the current shell, please specify one with --shell or run interactively")
|
||||
}
|
||||
// Silently continue to the shell selection if detecting failed in interactive mode
|
||||
choice, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Message: "Select a shell to install completion for:",
|
||||
Options: shellOptions.Choices,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
shellChoice, err := completion.ShellByName(choice, inv.Command.Parent.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if printOutput {
|
||||
return shellChoice.WriteCompletion(inv.Stdout)
|
||||
}
|
||||
return installCompletion(inv, shellChoice)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func installCompletion(inv *serpent.Invocation, shell completion.Shell) error {
|
||||
path, err := shell.InstallPath()
|
||||
if err != nil {
|
||||
cliui.Error(inv.Stderr, fmt.Sprintf("Failed to determine completion path %v", err))
|
||||
return shell.WriteCompletion(inv.Stdout)
|
||||
}
|
||||
if !isTTYOut(inv) {
|
||||
return shell.WriteCompletion(inv.Stdout)
|
||||
}
|
||||
choice, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: []string{
|
||||
"Confirm",
|
||||
"Print to terminal",
|
||||
},
|
||||
Message: fmt.Sprintf("Install completion for %s at %s?", shell.Name(), path),
|
||||
HideSearch: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if choice == "Print to terminal" {
|
||||
return shell.WriteCompletion(inv.Stdout)
|
||||
}
|
||||
return completion.InstallShellCompletion(shell)
|
||||
}
|
||||
+39
-79
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/natefinch/atomic"
|
||||
"github.com/pkg/diff"
|
||||
"github.com/pkg/diff/write"
|
||||
"golang.org/x/exp/constraints"
|
||||
@@ -54,6 +55,7 @@ type sshConfigOptions struct {
|
||||
disableAutostart bool
|
||||
header []string
|
||||
headerCommand string
|
||||
removedKeys map[string]bool
|
||||
}
|
||||
|
||||
// addOptions expects options in the form of "option=value" or "option value".
|
||||
@@ -74,30 +76,20 @@ func (o *sshConfigOptions) addOption(option string) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, existing := range o.sshOptions {
|
||||
// Override existing option if they share the same key.
|
||||
// This is case-insensitive. Parsing each time might be a little slow,
|
||||
// but it is ok.
|
||||
existingKey, _, err := codersdk.ParseSSHConfigOption(existing)
|
||||
if err != nil {
|
||||
// Don't mess with original values if there is an error.
|
||||
// This could have come from the user's manual edits.
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(existingKey, key) {
|
||||
if value == "" {
|
||||
// Delete existing option.
|
||||
o.sshOptions = append(o.sshOptions[:i], o.sshOptions[i+1:]...)
|
||||
} else {
|
||||
// Override existing option.
|
||||
o.sshOptions[i] = option
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lowerKey := strings.ToLower(key)
|
||||
if o.removedKeys != nil && o.removedKeys[lowerKey] {
|
||||
// Key marked as removed, skip.
|
||||
return nil
|
||||
}
|
||||
// Only append the option if it is not empty.
|
||||
// Only append the option if it is not empty
|
||||
// (we interpret empty as removal).
|
||||
if value != "" {
|
||||
o.sshOptions = append(o.sshOptions, option)
|
||||
} else {
|
||||
if o.removedKeys == nil {
|
||||
o.removedKeys = make(map[string]bool)
|
||||
}
|
||||
o.removedKeys[lowerKey] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -245,6 +237,8 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
|
||||
if sshConfigOpts.waitEnum != "auto" && skipProxyCommand {
|
||||
// The wait option is applied to the ProxyCommand. If the user
|
||||
// specifies skip-proxy-command, then wait cannot be applied.
|
||||
@@ -253,7 +247,14 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
sshConfigOpts.header = r.header
|
||||
sshConfigOpts.headerCommand = r.headerCommand
|
||||
|
||||
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(inv.Context(), client)
|
||||
// Talk to the API early to prevent the version mismatch
|
||||
// warning from being printed in the middle of a prompt.
|
||||
// This is needed because the asynchronous requests issued
|
||||
// by sshPrepareWorkspaceConfigs may otherwise trigger the
|
||||
// warning at any time.
|
||||
_, _ = client.BuildInfo(ctx)
|
||||
|
||||
recvWorkspaceConfigs := sshPrepareWorkspaceConfigs(ctx, client)
|
||||
|
||||
out := inv.Stdout
|
||||
if dryRun {
|
||||
@@ -375,7 +376,7 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
return xerrors.Errorf("fetch workspace configs failed: %w", err)
|
||||
}
|
||||
|
||||
coderdConfig, err := client.SSHConfiguration(inv.Context())
|
||||
coderdConfig, err := client.SSHConfiguration(ctx)
|
||||
if err != nil {
|
||||
// If the error is 404, this deployment does not support
|
||||
// this endpoint yet. Do not error, just assume defaults.
|
||||
@@ -440,13 +441,17 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
configOptions := sshConfigOpts
|
||||
configOptions.sshOptions = nil
|
||||
|
||||
// Add standard options.
|
||||
err := configOptions.addOptions(defaultOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
// User options first (SSH only uses the first
|
||||
// option unless it can be given multiple times)
|
||||
for _, opt := range sshConfigOpts.sshOptions {
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add flag config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Override with deployment options
|
||||
// Deployment options second, allow them to
|
||||
// override standard options.
|
||||
for k, v := range coderdConfig.SSHConfigOptions {
|
||||
opt := fmt.Sprintf("%s %s", k, v)
|
||||
err := configOptions.addOptions(opt)
|
||||
@@ -454,12 +459,11 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
return xerrors.Errorf("add coderd config option %q: %w", opt, err)
|
||||
}
|
||||
}
|
||||
// Override with flag options
|
||||
for _, opt := range sshConfigOpts.sshOptions {
|
||||
err := configOptions.addOptions(opt)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("add flag config option %q: %w", opt, err)
|
||||
}
|
||||
|
||||
// Finally, add the standard options.
|
||||
err := configOptions.addOptions(defaultOptions...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
hostBlock := []string{
|
||||
@@ -521,7 +525,7 @@ func (r *RootCmd) configSSH() *serpent.Command {
|
||||
}
|
||||
|
||||
if !bytes.Equal(configRaw, configModified) {
|
||||
err = writeWithTempFileAndMove(sshConfigFile, bytes.NewReader(configModified))
|
||||
err = atomic.WriteFile(sshConfigFile, bytes.NewReader(configModified))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("write ssh config failed: %w", err)
|
||||
}
|
||||
@@ -755,50 +759,6 @@ func sshConfigSplitOnCoderSection(data []byte) (before, section []byte, after []
|
||||
return data, nil, nil, nil
|
||||
}
|
||||
|
||||
// writeWithTempFileAndMove writes to a temporary file in the same
|
||||
// directory as path and renames the temp file to the file provided in
|
||||
// path. This ensure we avoid trashing the file we are writing due to
|
||||
// unforeseen circumstance like filesystem full, command killed, etc.
|
||||
func writeWithTempFileAndMove(path string, r io.Reader) (err error) {
|
||||
dir := filepath.Dir(path)
|
||||
name := filepath.Base(path)
|
||||
|
||||
// Ensure that e.g. the ~/.ssh directory exists.
|
||||
if err = os.MkdirAll(dir, 0o700); err != nil {
|
||||
return xerrors.Errorf("create directory: %w", err)
|
||||
}
|
||||
|
||||
// Create a tempfile in the same directory for ensuring write
|
||||
// operation does not fail.
|
||||
f, err := os.CreateTemp(dir, fmt.Sprintf(".%s.", name))
|
||||
if err != nil {
|
||||
return xerrors.Errorf("create temp file failed: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err != nil {
|
||||
_ = os.Remove(f.Name()) // Cleanup in case a step failed.
|
||||
}
|
||||
}()
|
||||
|
||||
_, err = io.Copy(f, r)
|
||||
if err != nil {
|
||||
_ = f.Close()
|
||||
return xerrors.Errorf("write temp file failed: %w", err)
|
||||
}
|
||||
|
||||
err = f.Close()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("close temp file failed: %w", err)
|
||||
}
|
||||
|
||||
err = os.Rename(f.Name(), path)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("rename temp file failed: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// sshConfigExecEscape quotes the string if it contains spaces, as per
|
||||
// `man 5 ssh_config`. However, OpenSSH uses exec in the users shell to
|
||||
// run the command, and as such the formatting/escape requirements
|
||||
|
||||
@@ -138,6 +138,7 @@ func Test_sshConfigSplitOnCoderSection(t *testing.T) {
|
||||
|
||||
// This test tries to mimic the behavior of OpenSSH
|
||||
// when executing e.g. a ProxyCommand.
|
||||
// nolint:tparallel
|
||||
func Test_sshConfigExecEscape(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -154,11 +155,10 @@ func Test_sshConfigExecEscape(t *testing.T) {
|
||||
{"tabs", "path with \ttabs", false},
|
||||
{"newline fails", "path with \nnewline", true},
|
||||
}
|
||||
// nolint:paralleltest // Fixes a flake
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("Windows doesn't typically execute via /bin/sh or cmd.exe, so this test is not applicable.")
|
||||
}
|
||||
@@ -272,24 +272,25 @@ func Test_sshConfigOptions_addOption(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "Replace",
|
||||
Name: "AddTwo",
|
||||
Start: []string{
|
||||
"foo bar",
|
||||
},
|
||||
Add: []string{"Foo baz"},
|
||||
Expect: []string{
|
||||
"foo bar",
|
||||
"Foo baz",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "AddAndReplace",
|
||||
Name: "AddAndRemove",
|
||||
Start: []string{
|
||||
"a b",
|
||||
"foo bar",
|
||||
"buzz bazz",
|
||||
},
|
||||
Add: []string{
|
||||
"b c",
|
||||
"a ", // Empty value, means remove all following entries that start with "a", i.e. next line.
|
||||
"A hello",
|
||||
"hello world",
|
||||
},
|
||||
@@ -297,7 +298,6 @@ func Test_sshConfigOptions_addOption(t *testing.T) {
|
||||
"foo bar",
|
||||
"buzz bazz",
|
||||
"b c",
|
||||
"A hello",
|
||||
"hello world",
|
||||
},
|
||||
},
|
||||
|
||||
+14
-1
@@ -65,7 +65,7 @@ func TestConfigSSH(t *testing.T) {
|
||||
|
||||
const hostname = "test-coder."
|
||||
const expectedKey = "ConnectionAttempts"
|
||||
const removeKey = "ConnectionTimeout"
|
||||
const removeKey = "ConnectTimeout"
|
||||
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{
|
||||
ConfigSSH: codersdk.SSHConfigResponse{
|
||||
HostnamePrefix: hostname,
|
||||
@@ -620,6 +620,19 @@ func TestConfigSSH_FileWriteAndOptionsFlow(t *testing.T) {
|
||||
regexMatch: `ProxyCommand .* --header-command "printf h1=v1 h2='v2'" ssh`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Multiple remote forwards",
|
||||
args: []string{
|
||||
"--yes",
|
||||
"--ssh-option", "RemoteForward 2222 192.168.11.1:2222",
|
||||
"--ssh-option", "RemoteForward 2223 192.168.11.1:2223",
|
||||
},
|
||||
wantErr: false,
|
||||
hasAgent: true,
|
||||
wantConfig: wantConfig{
|
||||
regexMatch: "RemoteForward 2222 192.168.11.1:2222.*\n.*RemoteForward 2223 192.168.11.1:2223",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
|
||||
+88
-13
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -29,6 +30,9 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
parameterFlags workspaceParameterFlags
|
||||
autoUpdates string
|
||||
copyParametersFrom string
|
||||
// Organization context is only required if more than 1 template
|
||||
// shares the same name across multiple organizations.
|
||||
orgContext = NewOrganizationContext()
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -43,11 +47,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
),
|
||||
Middleware: serpent.Chain(r.InitClient(client)),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
organization, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var err error
|
||||
workspaceOwner := codersdk.Me
|
||||
if len(inv.Args) >= 1 {
|
||||
workspaceOwner, workspaceName, err = splitNamedWorkspace(inv.Args[0])
|
||||
@@ -60,9 +60,13 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
workspaceName, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Specify a name for your workspace:",
|
||||
Validate: func(workspaceName string) error {
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), codersdk.Me, workspaceName, codersdk.WorkspaceOptions{})
|
||||
err = codersdk.NameValid(workspaceName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
|
||||
}
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
return xerrors.Errorf("a workspace already exists named %q", workspaceName)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
@@ -71,10 +75,13 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = codersdk.NameValid(workspaceName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("workspace name %q is invalid: %w", workspaceName, err)
|
||||
}
|
||||
_, err = client.WorkspaceByOwnerAndName(inv.Context(), workspaceOwner, workspaceName, codersdk.WorkspaceOptions{})
|
||||
if err == nil {
|
||||
return xerrors.Errorf("A workspace already exists named %q!", workspaceName)
|
||||
return xerrors.Errorf("a workspace already exists named %q", workspaceName)
|
||||
}
|
||||
|
||||
var sourceWorkspace codersdk.Workspace
|
||||
@@ -98,7 +105,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
if templateName == "" {
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select a template below to preview the provisioned infrastructure:"))
|
||||
|
||||
templates, err := client.TemplatesByOrganization(inv.Context(), organization.ID)
|
||||
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -110,13 +117,28 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
templateNames := make([]string, 0, len(templates))
|
||||
templateByName := make(map[string]codersdk.Template, len(templates))
|
||||
|
||||
// If more than 1 organization exists in the list of templates,
|
||||
// then include the organization name in the select options.
|
||||
uniqueOrganizations := make(map[uuid.UUID]bool)
|
||||
for _, template := range templates {
|
||||
uniqueOrganizations[template.OrganizationID] = true
|
||||
}
|
||||
|
||||
for _, template := range templates {
|
||||
templateName := template.Name
|
||||
if len(uniqueOrganizations) > 1 {
|
||||
templateName += cliui.Placeholder(
|
||||
fmt.Sprintf(
|
||||
" (%s)",
|
||||
template.OrganizationName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if template.ActiveUserCount > 0 {
|
||||
templateName += cliui.Placeholder(
|
||||
fmt.Sprintf(
|
||||
" (used by %s)",
|
||||
" used by %s",
|
||||
formatActiveDevelopers(template.ActiveUserCount),
|
||||
),
|
||||
)
|
||||
@@ -144,13 +166,65 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
}
|
||||
templateVersionID = sourceWorkspace.LatestBuild.TemplateVersionID
|
||||
} else {
|
||||
template, err = client.TemplateByName(inv.Context(), organization.ID, templateName)
|
||||
templates, err := client.Templates(inv.Context(), codersdk.TemplateFilter{
|
||||
ExactName: templateName,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template by name: %w", err)
|
||||
}
|
||||
if len(templates) == 0 {
|
||||
return xerrors.Errorf("no template found with the name %q", templateName)
|
||||
}
|
||||
|
||||
if len(templates) > 1 {
|
||||
templateOrgs := []string{}
|
||||
for _, tpl := range templates {
|
||||
templateOrgs = append(templateOrgs, tpl.OrganizationName)
|
||||
}
|
||||
|
||||
selectedOrg, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("multiple templates found with the name %q, use `--org=<organization_name>` to specify which template by that name to use. Organizations available: %s", templateName, strings.Join(templateOrgs, ", "))
|
||||
}
|
||||
|
||||
index := slices.IndexFunc(templates, func(i codersdk.Template) bool {
|
||||
return i.OrganizationID == selectedOrg.ID
|
||||
})
|
||||
if index == -1 {
|
||||
return xerrors.Errorf("no templates found with the name %q in the organization %q. Templates by that name exist in organizations: %s. Use --org=<organization_name> to select one.", templateName, selectedOrg.Name, strings.Join(templateOrgs, ", "))
|
||||
}
|
||||
|
||||
// remake the list with the only template selected
|
||||
templates = []codersdk.Template{templates[index]}
|
||||
}
|
||||
|
||||
template = templates[0]
|
||||
templateVersionID = template.ActiveVersionID
|
||||
}
|
||||
|
||||
// If the user specified an organization via a flag or env var, the template **must**
|
||||
// be in that organization. Otherwise, we should throw an error.
|
||||
orgValue, orgValueSource := orgContext.ValueSource(inv)
|
||||
if orgValue != "" && !(orgValueSource == serpent.ValueSourceDefault || orgValueSource == serpent.ValueSourceNone) {
|
||||
selectedOrg, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if template.OrganizationID != selectedOrg.ID {
|
||||
orgNameFormat := "'--org=%q'"
|
||||
if orgValueSource == serpent.ValueSourceEnv {
|
||||
orgNameFormat = "CODER_ORGANIZATION=%q"
|
||||
}
|
||||
|
||||
return xerrors.Errorf("template is in organization %q, but %s was specified. Use %s to use this template",
|
||||
template.OrganizationName,
|
||||
fmt.Sprintf(orgNameFormat, selectedOrg.Name),
|
||||
fmt.Sprintf(orgNameFormat, template.OrganizationName),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var schedSpec *string
|
||||
if startAt != "" {
|
||||
sched, err := parseCLISchedule(startAt)
|
||||
@@ -206,7 +280,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
ttlMillis = ptr.Ref(stopAfter.Milliseconds())
|
||||
}
|
||||
|
||||
workspace, err := client.CreateWorkspace(inv.Context(), organization.ID, workspaceOwner, codersdk.CreateWorkspaceRequest{
|
||||
workspace, err := client.CreateWorkspace(inv.Context(), template.OrganizationID, workspaceOwner, codersdk.CreateWorkspaceRequest{
|
||||
TemplateVersionID: templateVersionID,
|
||||
Name: workspaceName,
|
||||
AutostartSchedule: schedSpec,
|
||||
@@ -269,6 +343,7 @@ func (r *RootCmd) create() *serpent.Command {
|
||||
)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameters()...)
|
||||
cmd.Options = append(cmd.Options, parameterFlags.cliParameterDefaults()...)
|
||||
orgContext.AttachOptions(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
|
||||
+11
-3
@@ -11,7 +11,10 @@ import (
|
||||
|
||||
// nolint
|
||||
func (r *RootCmd) deleteWorkspace() *serpent.Command {
|
||||
var orphan bool
|
||||
var (
|
||||
orphan bool
|
||||
prov buildFlags
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -40,11 +43,15 @@ func (r *RootCmd) deleteWorkspace() *serpent.Command {
|
||||
}
|
||||
|
||||
var state []byte
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, codersdk.CreateWorkspaceBuildRequest{
|
||||
req := codersdk.CreateWorkspaceBuildRequest{
|
||||
Transition: codersdk.WorkspaceTransitionDelete,
|
||||
ProvisionerState: state,
|
||||
Orphan: orphan,
|
||||
})
|
||||
}
|
||||
if prov.provisionerLogDebug {
|
||||
req.LogLevel = codersdk.ProvisionerLogLevelDebug
|
||||
}
|
||||
build, err := client.CreateWorkspaceBuild(inv.Context(), workspace.ID, req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -71,5 +78,6 @@ func (r *RootCmd) deleteWorkspace() *serpent.Command {
|
||||
},
|
||||
cliui.SkipPromptOption(),
|
||||
}
|
||||
cmd.Options = append(cmd.Options, prov.cliOptions()...)
|
||||
return cmd
|
||||
}
|
||||
|
||||
+4
-5
@@ -27,7 +27,7 @@ func TestDelete(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y")
|
||||
clitest.SetupConfig(t, member, root)
|
||||
@@ -52,7 +52,7 @@ func TestDelete(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, owner.OrganizationID, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
inv, root := clitest.New(t, "delete", workspace.Name, "-y", "--orphan")
|
||||
|
||||
@@ -86,8 +86,7 @@ func TestDelete(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
|
||||
workspace := coderdtest.CreateWorkspace(t, deleteMeClient, owner.OrganizationID, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, deleteMeClient, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, deleteMeClient, workspace.LatestBuild.ID)
|
||||
|
||||
// The API checks if the user has any workspaces, so we cannot delete a user
|
||||
@@ -128,7 +127,7 @@ func TestDelete(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, adminClient, orgID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, adminClient, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, adminClient, orgID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, orgID, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
inv, root := clitest.New(t, "delete", user.Username+"/"+workspace.Name, "-y")
|
||||
|
||||
+5
-9
@@ -4,7 +4,6 @@ import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -184,7 +183,7 @@ func (r *RootCmd) dotfiles() *serpent.Command {
|
||||
}
|
||||
}
|
||||
|
||||
script := findScript(installScriptSet, files)
|
||||
script := findScript(installScriptSet, dotfilesDir)
|
||||
if script != "" {
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Running install script %s.\n\n Continue?", script),
|
||||
@@ -204,7 +203,7 @@ func (r *RootCmd) dotfiles() *serpent.Command {
|
||||
}
|
||||
|
||||
if fi.Mode()&0o111 == 0 {
|
||||
return xerrors.Errorf("script %q is not executable. See https://coder.com/docs/v2/latest/dotfiles for information on how to resolve the issue.", script)
|
||||
return xerrors.Errorf("script %q does not have execute permissions", script)
|
||||
}
|
||||
|
||||
// it is safe to use a variable command here because it's from
|
||||
@@ -361,15 +360,12 @@ func dirExists(name string) (bool, error) {
|
||||
}
|
||||
|
||||
// findScript will find the first file that matches the script set.
|
||||
func findScript(scriptSet []string, files []fs.DirEntry) string {
|
||||
func findScript(scriptSet []string, directory string) string {
|
||||
for _, i := range scriptSet {
|
||||
for _, f := range files {
|
||||
if f.Name() == i {
|
||||
return f.Name()
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(directory, i)); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
|
||||
@@ -142,6 +142,41 @@ func TestDotfiles(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow\n")
|
||||
})
|
||||
|
||||
t.Run("NestedInstallScript", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("install scripts on windows require sh and aren't very practical")
|
||||
}
|
||||
_, root := clitest.New(t)
|
||||
testRepo := testGitRepo(t, root)
|
||||
|
||||
scriptPath := filepath.Join("script", "setup")
|
||||
err := os.MkdirAll(filepath.Join(testRepo, "script"), 0o750)
|
||||
require.NoError(t, err)
|
||||
// nolint:gosec
|
||||
err = os.WriteFile(filepath.Join(testRepo, scriptPath), []byte("#!/bin/bash\necho wow > "+filepath.Join(string(root), ".bashrc")), 0o750)
|
||||
require.NoError(t, err)
|
||||
|
||||
c := exec.Command("git", "add", scriptPath)
|
||||
c.Dir = testRepo
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
c = exec.Command("git", "commit", "-m", `"add script"`)
|
||||
c.Dir = testRepo
|
||||
err = c.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, _ := clitest.New(t, "dotfiles", "--global-config", string(root), "--symlink-dir", string(root), "-y", testRepo)
|
||||
err = inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
b, err := os.ReadFile(filepath.Join(string(root), ".bashrc"))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(b), "wow\n")
|
||||
})
|
||||
|
||||
t.Run("InstallScriptChangeBranch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if runtime.GOOS == "windows" {
|
||||
|
||||
@@ -13,6 +13,7 @@ func (r *RootCmd) expCmd() *serpent.Command {
|
||||
Children: []*serpent.Command{
|
||||
r.scaletestCmd(),
|
||||
r.errorExample(),
|
||||
r.promptExample(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
|
||||
+42
-8
@@ -117,7 +117,7 @@ func (s *scaletestTracingFlags) provider(ctx context.Context) (trace.TracerProvi
|
||||
}
|
||||
|
||||
var closeTracingOnce sync.Once
|
||||
return tracerProvider, func(ctx context.Context) error {
|
||||
return tracerProvider, func(_ context.Context) error {
|
||||
var err error
|
||||
closeTracingOnce.Do(func() {
|
||||
// Allow time to upload traces even if ctx is canceled
|
||||
@@ -430,7 +430,7 @@ func (r *RootCmd) scaletestCleanup() *serpent.Command {
|
||||
}
|
||||
|
||||
cliui.Infof(inv.Stdout, "Fetching scaletest workspaces...")
|
||||
workspaces, err := getScaletestWorkspaces(ctx, client, template)
|
||||
workspaces, _, err := getScaletestWorkspaces(ctx, client, "", template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -863,6 +863,7 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
tickInterval time.Duration
|
||||
bytesPerTick int64
|
||||
ssh bool
|
||||
useHostLogin bool
|
||||
app string
|
||||
template string
|
||||
targetWorkspaces string
|
||||
@@ -926,10 +927,18 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
return xerrors.Errorf("get app host: %w", err)
|
||||
}
|
||||
|
||||
workspaces, err := getScaletestWorkspaces(inv.Context(), client, template)
|
||||
var owner string
|
||||
if useHostLogin {
|
||||
owner = codersdk.Me
|
||||
}
|
||||
|
||||
workspaces, numSkipped, err := getScaletestWorkspaces(inv.Context(), client, owner, template)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if numSkipped > 0 {
|
||||
cliui.Warnf(inv.Stdout, "CODER_DISABLE_OWNER_WORKSPACE_ACCESS is set on the deployment.\n\t%d workspace(s) were skipped due to ownership mismatch.\n\tSet --use-host-login to only target workspaces you own.", numSkipped)
|
||||
}
|
||||
|
||||
if targetWorkspaceEnd == 0 {
|
||||
targetWorkspaceEnd = len(workspaces)
|
||||
@@ -1092,6 +1101,13 @@ func (r *RootCmd) scaletestWorkspaceTraffic() *serpent.Command {
|
||||
Description: "Send WebSocket traffic to a workspace app (proxied via coderd), cannot be used with --ssh.",
|
||||
Value: serpent.StringOf(&app),
|
||||
},
|
||||
{
|
||||
Flag: "use-host-login",
|
||||
Env: "CODER_SCALETEST_USE_HOST_LOGIN",
|
||||
Default: "false",
|
||||
Description: "Connect as the currently logged in user.",
|
||||
Value: serpent.BoolOf(&useHostLogin),
|
||||
},
|
||||
}
|
||||
|
||||
tracingFlags.attach(&cmd.Options)
|
||||
@@ -1378,22 +1394,35 @@ func isScaleTestWorkspace(workspace codersdk.Workspace) bool {
|
||||
strings.HasPrefix(workspace.Name, "scaletest-")
|
||||
}
|
||||
|
||||
func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, template string) ([]codersdk.Workspace, error) {
|
||||
func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, owner, template string) ([]codersdk.Workspace, int, error) {
|
||||
var (
|
||||
pageNumber = 0
|
||||
limit = 100
|
||||
workspaces []codersdk.Workspace
|
||||
skipped int
|
||||
)
|
||||
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
if err != nil {
|
||||
return nil, 0, xerrors.Errorf("check logged-in user")
|
||||
}
|
||||
|
||||
dv, err := client.DeploymentConfig(ctx)
|
||||
if err != nil {
|
||||
return nil, 0, xerrors.Errorf("fetch deployment config: %w", err)
|
||||
}
|
||||
noOwnerAccess := dv.Values != nil && dv.Values.DisableOwnerWorkspaceExec.Value()
|
||||
|
||||
for {
|
||||
page, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
|
||||
Name: "scaletest-",
|
||||
Template: template,
|
||||
Owner: owner,
|
||||
Offset: pageNumber * limit,
|
||||
Limit: limit,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("fetch scaletest workspaces page %d: %w", pageNumber, err)
|
||||
return nil, 0, xerrors.Errorf("fetch scaletest workspaces page %d: %w", pageNumber, err)
|
||||
}
|
||||
|
||||
pageNumber++
|
||||
@@ -1403,13 +1432,18 @@ func getScaletestWorkspaces(ctx context.Context, client *codersdk.Client, templa
|
||||
|
||||
pageWorkspaces := make([]codersdk.Workspace, 0, len(page.Workspaces))
|
||||
for _, w := range page.Workspaces {
|
||||
if isScaleTestWorkspace(w) {
|
||||
pageWorkspaces = append(pageWorkspaces, w)
|
||||
if !isScaleTestWorkspace(w) {
|
||||
continue
|
||||
}
|
||||
if noOwnerAccess && w.OwnerID != me.ID {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
pageWorkspaces = append(pageWorkspaces, w)
|
||||
}
|
||||
workspaces = append(workspaces, pageWorkspaces...)
|
||||
}
|
||||
return workspaces, nil
|
||||
return workspaces, skipped, nil
|
||||
}
|
||||
|
||||
func getScaletestUsers(ctx context.Context, client *codersdk.Client) ([]codersdk.User, error) {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
package exptest_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"cdr.dev/slog/sloggers/slogtest"
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
// This test validates that the scaletest CLI filters out workspaces not owned
|
||||
// when disable owner workspace access is set.
|
||||
// This test is in its own package because it mutates a global variable that
|
||||
// can influence other tests in the same package.
|
||||
// nolint:paralleltest
|
||||
func TestScaleTestWorkspaceTraffic_UseHostLogin(t *testing.T) {
|
||||
ctx, cancelFunc := context.WithTimeout(context.Background(), testutil.WaitMedium)
|
||||
defer cancelFunc()
|
||||
|
||||
log := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true})
|
||||
client := coderdtest.New(t, &coderdtest.Options{
|
||||
Logger: &log,
|
||||
IncludeProvisionerDaemon: true,
|
||||
DeploymentValues: coderdtest.DeploymentValues(t, func(dv *codersdk.DeploymentValues) {
|
||||
dv.DisableOwnerWorkspaceExec = true
|
||||
}),
|
||||
})
|
||||
owner := coderdtest.CreateFirstUser(t, client)
|
||||
tv := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
_ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, tv.ID)
|
||||
tpl := coderdtest.CreateTemplate(t, client, owner.OrganizationID, tv.ID)
|
||||
// Create a workspace owned by a different user
|
||||
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
|
||||
_ = coderdtest.CreateWorkspace(t, memberClient, tpl.ID, func(cwr *codersdk.CreateWorkspaceRequest) {
|
||||
cwr.Name = "scaletest-workspace"
|
||||
})
|
||||
|
||||
// Test without --use-host-login first.g
|
||||
inv, root := clitest.New(t, "exp", "scaletest", "workspace-traffic",
|
||||
"--template", tpl.Name,
|
||||
)
|
||||
// nolint:gocritic // We are intentionally testing this as the owner.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
var stdoutBuf bytes.Buffer
|
||||
inv.Stdout = &stdoutBuf
|
||||
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "no scaletest workspaces exist")
|
||||
require.Contains(t, stdoutBuf.String(), `1 workspace(s) were skipped`)
|
||||
|
||||
// Test once again with --use-host-login.
|
||||
inv, root = clitest.New(t, "exp", "scaletest", "workspace-traffic",
|
||||
"--template", tpl.Name,
|
||||
"--use-host-login",
|
||||
)
|
||||
// nolint:gocritic // We are intentionally testing this as the owner.
|
||||
clitest.SetupConfig(t, client, root)
|
||||
stdoutBuf.Reset()
|
||||
inv.Stdout = &stdoutBuf
|
||||
|
||||
err = inv.WithContext(ctx).Run()
|
||||
require.ErrorContains(t, err, "no scaletest workspaces exist")
|
||||
require.NotContains(t, stdoutBuf.String(), `1 workspace(s) were skipped`)
|
||||
}
|
||||
@@ -81,6 +81,8 @@ var usageTemplate = func() *template.Template {
|
||||
switch v := opt.Value.(type) {
|
||||
case *serpent.Enum:
|
||||
return strings.Join(v.Choices, "|")
|
||||
case *serpent.EnumArray:
|
||||
return fmt.Sprintf("[%s]", strings.Join(v.Choices, "|"))
|
||||
default:
|
||||
return v.Type()
|
||||
}
|
||||
|
||||
+32
-27
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
@@ -22,19 +23,21 @@ type workspaceListRow struct {
|
||||
codersdk.Workspace `table:"-"`
|
||||
|
||||
// For table format:
|
||||
Favorite bool `json:"-" table:"favorite"`
|
||||
WorkspaceName string `json:"-" table:"workspace,default_sort"`
|
||||
Template string `json:"-" table:"template"`
|
||||
Status string `json:"-" table:"status"`
|
||||
Healthy string `json:"-" table:"healthy"`
|
||||
LastBuilt string `json:"-" table:"last built"`
|
||||
CurrentVersion string `json:"-" table:"current version"`
|
||||
Outdated bool `json:"-" table:"outdated"`
|
||||
StartsAt string `json:"-" table:"starts at"`
|
||||
StartsNext string `json:"-" table:"starts next"`
|
||||
StopsAfter string `json:"-" table:"stops after"`
|
||||
StopsNext string `json:"-" table:"stops next"`
|
||||
DailyCost string `json:"-" table:"daily cost"`
|
||||
Favorite bool `json:"-" table:"favorite"`
|
||||
WorkspaceName string `json:"-" table:"workspace,default_sort"`
|
||||
OrganizationID uuid.UUID `json:"-" table:"organization id"`
|
||||
OrganizationName string `json:"-" table:"organization name"`
|
||||
Template string `json:"-" table:"template"`
|
||||
Status string `json:"-" table:"status"`
|
||||
Healthy string `json:"-" table:"healthy"`
|
||||
LastBuilt string `json:"-" table:"last built"`
|
||||
CurrentVersion string `json:"-" table:"current version"`
|
||||
Outdated bool `json:"-" table:"outdated"`
|
||||
StartsAt string `json:"-" table:"starts at"`
|
||||
StartsNext string `json:"-" table:"starts next"`
|
||||
StopsAfter string `json:"-" table:"stops after"`
|
||||
StopsNext string `json:"-" table:"stops next"`
|
||||
DailyCost string `json:"-" table:"daily cost"`
|
||||
}
|
||||
|
||||
func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace) workspaceListRow {
|
||||
@@ -53,20 +56,22 @@ func workspaceListRowFromWorkspace(now time.Time, workspace codersdk.Workspace)
|
||||
}
|
||||
workspaceName := favIco + " " + workspace.OwnerName + "/" + workspace.Name
|
||||
return workspaceListRow{
|
||||
Favorite: workspace.Favorite,
|
||||
Workspace: workspace,
|
||||
WorkspaceName: workspaceName,
|
||||
Template: workspace.TemplateName,
|
||||
Status: status,
|
||||
Healthy: healthy,
|
||||
LastBuilt: durationDisplay(lastBuilt),
|
||||
CurrentVersion: workspace.LatestBuild.TemplateVersionName,
|
||||
Outdated: workspace.Outdated,
|
||||
StartsAt: schedRow.StartsAt,
|
||||
StartsNext: schedRow.StartsNext,
|
||||
StopsAfter: schedRow.StopsAfter,
|
||||
StopsNext: schedRow.StopsNext,
|
||||
DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)),
|
||||
Favorite: workspace.Favorite,
|
||||
Workspace: workspace,
|
||||
WorkspaceName: workspaceName,
|
||||
OrganizationID: workspace.OrganizationID,
|
||||
OrganizationName: workspace.OrganizationName,
|
||||
Template: workspace.TemplateName,
|
||||
Status: status,
|
||||
Healthy: healthy,
|
||||
LastBuilt: durationDisplay(lastBuilt),
|
||||
CurrentVersion: workspace.LatestBuild.TemplateVersionName,
|
||||
Outdated: workspace.Outdated,
|
||||
StartsAt: schedRow.StartsAt,
|
||||
StartsNext: schedRow.StartsNext,
|
||||
StopsAfter: schedRow.StopsAfter,
|
||||
StopsNext: schedRow.StopsNext,
|
||||
DailyCost: strconv.Itoa(int(workspace.LatestBuild.DailyCost)),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+33
-2
@@ -58,6 +58,21 @@ func promptFirstUsername(inv *serpent.Invocation) (string, error) {
|
||||
return username, nil
|
||||
}
|
||||
|
||||
func promptFirstName(inv *serpent.Invocation) (string, error) {
|
||||
name, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "(Optional) What " + pretty.Sprint(cliui.DefaultStyles.Field, "name") + " would you like?",
|
||||
Default: "",
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, cliui.Canceled) {
|
||||
return "", nil
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func promptFirstPassword(inv *serpent.Invocation) (string, error) {
|
||||
retry:
|
||||
password, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
@@ -130,6 +145,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
var (
|
||||
email string
|
||||
username string
|
||||
name string
|
||||
password string
|
||||
trial bool
|
||||
useTokenForSession bool
|
||||
@@ -191,11 +207,12 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Attempting to authenticate with %s URL: '%s'\n", urlSource, serverURL)
|
||||
|
||||
// nolint: nestif
|
||||
if !hasFirstUser {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, Caret+"Your Coder deployment hasn't been set up!\n")
|
||||
|
||||
if username == "" {
|
||||
if !isTTY(inv) {
|
||||
if !isTTYIn(inv) {
|
||||
return xerrors.New("the initial user cannot be created in non-interactive mode. use the API")
|
||||
}
|
||||
|
||||
@@ -212,6 +229,10 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
name, err = promptFirstName(inv)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if email == "" {
|
||||
@@ -239,7 +260,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
|
||||
if !inv.ParsedFlags().Changed("first-user-trial") && os.Getenv(firstUserTrialEnv) == "" {
|
||||
v, _ := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Start a 30-day trial of Enterprise?",
|
||||
Text: "Start a trial of Enterprise?",
|
||||
IsConfirm: true,
|
||||
Default: "yes",
|
||||
})
|
||||
@@ -249,6 +270,7 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
_, err = client.CreateFirstUser(ctx, codersdk.CreateFirstUserRequest{
|
||||
Email: email,
|
||||
Username: username,
|
||||
Name: name,
|
||||
Password: password,
|
||||
Trial: trial,
|
||||
})
|
||||
@@ -353,6 +375,12 @@ func (r *RootCmd) login() *serpent.Command {
|
||||
Description: "Specifies a username to use if creating the first user for the deployment.",
|
||||
Value: serpent.StringOf(&username),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-full-name",
|
||||
Env: "CODER_FIRST_USER_FULL_NAME",
|
||||
Description: "Specifies a human-readable name for the first user of the deployment.",
|
||||
Value: serpent.StringOf(&name),
|
||||
},
|
||||
{
|
||||
Flag: "first-user-password",
|
||||
Env: "CODER_FIRST_USER_PASSWORD",
|
||||
@@ -388,6 +416,9 @@ func isWSL() (bool, error) {
|
||||
|
||||
// openURL opens the provided URL via user's default browser
|
||||
func openURL(inv *serpent.Invocation, urlToOpen string) error {
|
||||
if !isTTYOut(inv) {
|
||||
return xerrors.New("skipping browser open in non-interactive mode")
|
||||
}
|
||||
noOpen, err := inv.ParsedFlags().GetBool(varNoOpen)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
|
||||
+154
-16
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestLogin(t *testing.T) {
|
||||
@@ -89,10 +90,11 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "SomeSecurePassword!",
|
||||
"password", "SomeSecurePassword!", // Confirm.
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
@@ -103,6 +105,64 @@ func TestLogin(t *testing.T) {
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
<-doneChan
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYNameOptional", 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", "--force-tty", client.URL.String())
|
||||
pty := ptytest.New(t).Attach(root)
|
||||
go func() {
|
||||
defer close(doneChan)
|
||||
err := root.Run()
|
||||
assert.NoError(t, err)
|
||||
}()
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", "",
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.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
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
assert.Empty(t, me.Name)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYFlag", func(t *testing.T) {
|
||||
@@ -119,10 +179,11 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch(fmt.Sprintf("Attempting to authenticate with flag URL: '%s'", client.URL.String()))
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "SomeSecurePassword!",
|
||||
"password", "SomeSecurePassword!", // Confirm.
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", coderdtest.FirstUserParams.Password, // confirm
|
||||
"trial", "yes",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
@@ -132,6 +193,18 @@ func TestLogin(t *testing.T) {
|
||||
pty.WriteLine(value)
|
||||
}
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlags", func(t *testing.T) {
|
||||
@@ -139,13 +212,56 @@ func TestLogin(t *testing.T) {
|
||||
client := coderdtest.New(t, nil)
|
||||
inv, _ := clitest.New(
|
||||
t, "login", client.URL.String(),
|
||||
"--first-user-username", "testuser", "--first-user-email", "user@coder.com",
|
||||
"--first-user-password", "SomeSecurePassword!", "--first-user-trial",
|
||||
"--first-user-username", coderdtest.FirstUserParams.Username,
|
||||
"--first-user-full-name", coderdtest.FirstUserParams.Name,
|
||||
"--first-user-email", coderdtest.FirstUserParams.Email,
|
||||
"--first-user-password", coderdtest.FirstUserParams.Password,
|
||||
"--first-user-trial",
|
||||
)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
w.RequireSuccess()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Name, me.Name)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
})
|
||||
|
||||
t.Run("InitialUserFlagsNameOptional", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
inv, _ := clitest.New(
|
||||
t, "login", client.URL.String(),
|
||||
"--first-user-username", coderdtest.FirstUserParams.Username,
|
||||
"--first-user-email", coderdtest.FirstUserParams.Email,
|
||||
"--first-user-password", coderdtest.FirstUserParams.Password,
|
||||
"--first-user-trial",
|
||||
)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
w := clitest.StartWithWaiter(t, inv)
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
w.RequireSuccess()
|
||||
ctx := testutil.Context(t, testutil.WaitShort)
|
||||
resp, err := client.LoginWithPassword(ctx, codersdk.LoginWithPasswordRequest{
|
||||
Email: coderdtest.FirstUserParams.Email,
|
||||
Password: coderdtest.FirstUserParams.Password,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
client.SetSessionToken(resp.SessionToken)
|
||||
me, err := client.User(ctx, codersdk.Me)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Username, me.Username)
|
||||
assert.Equal(t, coderdtest.FirstUserParams.Email, me.Email)
|
||||
assert.Empty(t, me.Name)
|
||||
})
|
||||
|
||||
t.Run("InitialUserTTYConfirmPasswordFailAndReprompt", func(t *testing.T) {
|
||||
@@ -167,10 +283,11 @@ func TestLogin(t *testing.T) {
|
||||
|
||||
matches := []string{
|
||||
"first user?", "yes",
|
||||
"username", "testuser",
|
||||
"email", "user@coder.com",
|
||||
"password", "MyFirstSecurePassword!",
|
||||
"password", "MyNonMatchingSecurePassword!", // Confirm.
|
||||
"username", coderdtest.FirstUserParams.Username,
|
||||
"name", coderdtest.FirstUserParams.Name,
|
||||
"email", coderdtest.FirstUserParams.Email,
|
||||
"password", coderdtest.FirstUserParams.Password,
|
||||
"password", "something completely different",
|
||||
}
|
||||
for i := 0; i < len(matches); i += 2 {
|
||||
match := matches[i]
|
||||
@@ -183,9 +300,9 @@ func TestLogin(t *testing.T) {
|
||||
pty.ExpectMatch("Passwords do not match")
|
||||
pty.ExpectMatch("Enter a " + pretty.Sprint(cliui.DefaultStyles.Field, "password"))
|
||||
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.WriteLine(coderdtest.FirstUserParams.Password)
|
||||
pty.ExpectMatch("Confirm")
|
||||
pty.WriteLine("SomeSecurePassword!")
|
||||
pty.WriteLine(coderdtest.FirstUserParams.Password)
|
||||
pty.ExpectMatch("trial")
|
||||
pty.WriteLine("yes")
|
||||
pty.ExpectMatch("Welcome to Coder")
|
||||
@@ -304,4 +421,25 @@ func TestLogin(t *testing.T) {
|
||||
// This **should not be equal** to the token we passed in.
|
||||
require.NotEqual(t, client.SessionToken(), sessionFile)
|
||||
})
|
||||
|
||||
t.Run("KeepOrganizationContext", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, client)
|
||||
root, cfg := clitest.New(t, "login", client.URL.String(), "--token", client.SessionToken())
|
||||
|
||||
err := cfg.Organization().Write(first.OrganizationID.String())
|
||||
require.NoError(t, err, "write bad org to config")
|
||||
|
||||
err = root.Run()
|
||||
require.NoError(t, err)
|
||||
sessionFile, err := cfg.Session().Read()
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, client.SessionToken(), sessionFile)
|
||||
|
||||
// Organization config should be deleted since the org does not exist
|
||||
selected, err := cfg.Organization().Read()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, selected, first.OrganizationID.String())
|
||||
})
|
||||
}
|
||||
|
||||
+13
-2
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/coder/coder/v2/coderd/healthcheck/derphealth"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
@@ -34,11 +35,21 @@ func (r *RootCmd) netcheck() *serpent.Command {
|
||||
|
||||
_, _ = fmt.Fprint(inv.Stderr, "Gathering a network report. This may take a few seconds...\n\n")
|
||||
|
||||
var report derphealth.Report
|
||||
report.Run(ctx, &derphealth.ReportOptions{
|
||||
var derpReport derphealth.Report
|
||||
derpReport.Run(ctx, &derphealth.ReportOptions{
|
||||
DERPMap: connInfo.DERPMap,
|
||||
})
|
||||
|
||||
ifReport, err := healthsdk.RunInterfacesReport()
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to run interfaces report: %w", err)
|
||||
}
|
||||
|
||||
report := healthsdk.ClientNetcheckReport{
|
||||
DERP: healthsdk.DERPHealthReport(derpReport),
|
||||
Interfaces: ifReport,
|
||||
}
|
||||
|
||||
raw, err := json.MarshalIndent(report, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
@@ -27,12 +26,13 @@ func TestNetcheck(t *testing.T) {
|
||||
|
||||
b := out.Bytes()
|
||||
t.Log(string(b))
|
||||
var report healthsdk.DERPHealthReport
|
||||
var report healthsdk.ClientNetcheckReport
|
||||
require.NoError(t, json.Unmarshal(b, &report))
|
||||
|
||||
assert.True(t, report.Healthy)
|
||||
require.Len(t, report.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region
|
||||
for _, v := range report.Regions {
|
||||
// We do not assert that the report is healthy, just that
|
||||
// it has the expected number of reports per region.
|
||||
require.Len(t, report.DERP.Regions, 1+1) // 1 built-in region + 1 test-managed STUN region
|
||||
for _, v := range report.DERP.Regions {
|
||||
require.Len(t, v.NodeReports, len(v.Region.Nodes))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/serpent"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
)
|
||||
|
||||
func (r *RootCmd) notifications() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "notifications",
|
||||
Short: "Manage Coder notifications",
|
||||
Long: "Administrators can use these commands to change notification settings.\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Pause Coder notifications. Administrators can temporarily stop notifiers from dispatching messages in case of the target outage (for example: unavailable SMTP server or Webhook not responding).",
|
||||
Command: "coder notifications pause",
|
||||
},
|
||||
Example{
|
||||
Description: "Resume Coder notifications",
|
||||
Command: "coder notifications resume",
|
||||
},
|
||||
),
|
||||
Aliases: []string{"notification"},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.pauseNotifications(),
|
||||
r.resumeNotifications(),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) pauseNotifications() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "pause",
|
||||
Short: "Pause notifications",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
err := client.PutNotificationsSettings(inv.Context(), codersdk.NotificationsSettings{
|
||||
NotifierPaused: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to pause notifications: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Notifications are now paused.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) resumeNotifications() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "resume",
|
||||
Short: "Resume notifications",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
err := client.PutNotificationsSettings(inv.Context(), codersdk.NotificationsSettings{
|
||||
NotifierPaused: false,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("unable to resume notifications: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Notifications are now resumed.")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func createOpts(t *testing.T) *coderdtest.Options {
|
||||
t.Helper()
|
||||
|
||||
dt := coderdtest.DeploymentValues(t)
|
||||
return &coderdtest.Options{
|
||||
DeploymentValues: dt,
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifications(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
command string
|
||||
expectPaused bool
|
||||
}{
|
||||
{
|
||||
name: "PauseNotifications",
|
||||
command: "pause",
|
||||
expectPaused: true,
|
||||
},
|
||||
{
|
||||
name: "ResumeNotifications",
|
||||
command: "resume",
|
||||
expectPaused: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
tt := tt
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// given
|
||||
ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
|
||||
_ = coderdtest.CreateFirstUser(t, ownerClient)
|
||||
|
||||
// when
|
||||
inv, root := clitest.New(t, "notifications", tt.command)
|
||||
clitest.SetupConfig(t, ownerClient, root)
|
||||
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err := inv.Run()
|
||||
require.NoError(t, err)
|
||||
|
||||
// then
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
t.Cleanup(cancel)
|
||||
settingsJSON, err := db.GetNotificationsSettings(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var settings codersdk.NotificationsSettings
|
||||
err = json.Unmarshal([]byte(settingsJSON), &settings)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.expectPaused, settings.NotifierPaused)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPauseNotifications_RegularUser(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// given
|
||||
ownerClient, db := coderdtest.NewWithDatabase(t, createOpts(t))
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
anotherClient, _ := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID)
|
||||
|
||||
// when
|
||||
inv, root := clitest.New(t, "notifications", "pause")
|
||||
clitest.SetupConfig(t, anotherClient, root)
|
||||
|
||||
var buf bytes.Buffer
|
||||
inv.Stdout = &buf
|
||||
err := inv.Run()
|
||||
var sdkError *codersdk.Error
|
||||
require.Error(t, err)
|
||||
require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error")
|
||||
assert.Equal(t, http.StatusForbidden, sdkError.StatusCode())
|
||||
assert.Contains(t, sdkError.Message, "Forbidden.")
|
||||
|
||||
// then
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||
t.Cleanup(cancel)
|
||||
settingsJSON, err := db.GetNotificationsSettings(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
var settings codersdk.NotificationsSettings
|
||||
err = json.Unmarshal([]byte(settingsJSON), &settings)
|
||||
require.NoError(t, err)
|
||||
require.False(t, settings.NotifierPaused) // still running
|
||||
}
|
||||
+5
-2
@@ -35,8 +35,9 @@ const vscodeDesktopName = "VS Code Desktop"
|
||||
|
||||
func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
var (
|
||||
generateToken bool
|
||||
testOpenError bool
|
||||
generateToken bool
|
||||
testOpenError bool
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
@@ -47,6 +48,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(1, 2),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
@@ -79,6 +81,7 @@ func (r *RootCmd) openVSCode() *serpent.Command {
|
||||
Fetch: client.WorkspaceAgent,
|
||||
FetchLogs: nil,
|
||||
Wait: false,
|
||||
DocsURL: appearanceConfig.DocsURL,
|
||||
})
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.Canceled) {
|
||||
|
||||
+37
-189
@@ -1,213 +1,40 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/config"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/pretty"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizations() *serpent.Command {
|
||||
orgContext := NewOrganizationContext()
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
Use: "organizations [subcommand]",
|
||||
Short: "Organization related commands",
|
||||
Aliases: []string{"organization", "org", "orgs"},
|
||||
Hidden: true, // Hidden until these commands are complete.
|
||||
Use: "organizations [subcommand]",
|
||||
Short: "Organization related commands",
|
||||
Aliases: []string{"organization", "org", "orgs"},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.currentOrganization(),
|
||||
r.switchOrganization(),
|
||||
r.showOrganization(orgContext),
|
||||
r.createOrganization(),
|
||||
r.organizationRoles(),
|
||||
r.organizationMembers(orgContext),
|
||||
r.organizationRoles(orgContext),
|
||||
r.organizationSettings(orgContext),
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Options = serpent.OptionSet{}
|
||||
orgContext.AttachOptions(cmd)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) switchOrganization() *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "set <organization name | ID>",
|
||||
Short: "set the organization used by the CLI. Pass an empty string to reset to the default organization.",
|
||||
Long: "set the organization used by the CLI. Pass an empty string to reset to the default organization.\n" + FormatExamples(
|
||||
Example{
|
||||
Description: "Remove the current organization and defer to the default.",
|
||||
Command: "coder organizations set ''",
|
||||
},
|
||||
Example{
|
||||
Description: "Switch to a custom organization.",
|
||||
Command: "coder organizations set my-org",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireRangeArgs(0, 1),
|
||||
),
|
||||
Options: serpent.OptionSet{},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
conf := r.createConfig()
|
||||
orgs, err := client.OrganizationsByUser(inv.Context(), codersdk.Me)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to get organizations: %w", err)
|
||||
}
|
||||
// Keep the list of orgs sorted
|
||||
slices.SortFunc(orgs, func(a, b codersdk.Organization) int {
|
||||
return strings.Compare(a.Name, b.Name)
|
||||
})
|
||||
|
||||
var switchToOrg string
|
||||
if len(inv.Args) == 0 {
|
||||
// Pull switchToOrg from a prompt selector, rather than command line
|
||||
// args.
|
||||
switchToOrg, err = promptUserSelectOrg(inv, conf, orgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
switchToOrg = inv.Args[0]
|
||||
}
|
||||
|
||||
// If the user passes an empty string, we want to remove the organization
|
||||
// from the config file. This will defer to default behavior.
|
||||
if switchToOrg == "" {
|
||||
err := conf.Organization().Delete()
|
||||
if err != nil && !errors.Is(err, os.ErrNotExist) {
|
||||
return xerrors.Errorf("failed to unset organization: %w", err)
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Organization unset\n")
|
||||
} else {
|
||||
// Find the selected org in our list.
|
||||
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
|
||||
return org.Name == switchToOrg || org.ID.String() == switchToOrg
|
||||
})
|
||||
if index < 0 {
|
||||
// Using this error for better error message formatting
|
||||
err := &codersdk.Error{
|
||||
Response: codersdk.Response{
|
||||
Message: fmt.Sprintf("Organization %q not found. Is the name correct, and are you a member of it?", switchToOrg),
|
||||
Detail: "Ensure the organization argument is correct and you are a member of it.",
|
||||
},
|
||||
Helper: fmt.Sprintf("Valid organizations you can switch to: %s", strings.Join(orgNames(orgs), ", ")),
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Always write the uuid to the config file. Names can change.
|
||||
err := conf.Organization().Write(orgs[index].ID.String())
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to write organization to config file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Verify it worked.
|
||||
current, err := CurrentOrganization(r, inv, client)
|
||||
if err != nil {
|
||||
// An SDK error could be a permission error. So offer the advice to unset the org
|
||||
// and reset the context.
|
||||
var sdkError *codersdk.Error
|
||||
if errors.As(err, &sdkError) {
|
||||
if sdkError.Helper == "" && sdkError.StatusCode() != 500 {
|
||||
sdkError.Helper = `If this error persists, try unsetting your org with 'coder organizations set ""'`
|
||||
}
|
||||
return sdkError
|
||||
}
|
||||
return xerrors.Errorf("failed to get current organization: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Current organization context set to %s (%s)\n", current.Name, current.ID.String())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// promptUserSelectOrg will prompt the user to select an organization from a list
|
||||
// of their organizations.
|
||||
func promptUserSelectOrg(inv *serpent.Invocation, conf config.Root, orgs []codersdk.Organization) (string, error) {
|
||||
// Default choice
|
||||
var defaultOrg string
|
||||
// Comes from config file
|
||||
if conf.Organization().Exists() {
|
||||
defaultOrg, _ = conf.Organization().Read()
|
||||
}
|
||||
|
||||
// No config? Comes from default org in the list
|
||||
if defaultOrg == "" {
|
||||
defIndex := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
|
||||
return org.IsDefault
|
||||
})
|
||||
if defIndex >= 0 {
|
||||
defaultOrg = orgs[defIndex].Name
|
||||
}
|
||||
}
|
||||
|
||||
// Defer to first org
|
||||
if defaultOrg == "" && len(orgs) > 0 {
|
||||
defaultOrg = orgs[0].Name
|
||||
}
|
||||
|
||||
// Ensure the `defaultOrg` value is an org name, not a uuid.
|
||||
// If it is a uuid, change it to the org name.
|
||||
index := slices.IndexFunc(orgs, func(org codersdk.Organization) bool {
|
||||
return org.ID.String() == defaultOrg || org.Name == defaultOrg
|
||||
})
|
||||
if index >= 0 {
|
||||
defaultOrg = orgs[index].Name
|
||||
}
|
||||
|
||||
// deselectOption is the option to delete the organization config file and defer
|
||||
// to default behavior.
|
||||
const deselectOption = "[Default]"
|
||||
if defaultOrg == "" {
|
||||
defaultOrg = deselectOption
|
||||
}
|
||||
|
||||
// Pull value from a prompt
|
||||
_, _ = fmt.Fprintln(inv.Stdout, pretty.Sprint(cliui.DefaultStyles.Wrap, "Select an organization below to set the current CLI context to:"))
|
||||
value, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: append([]string{deselectOption}, orgNames(orgs)...),
|
||||
Default: defaultOrg,
|
||||
Size: 10,
|
||||
HideSearch: false,
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
// Deselect is an alias for ""
|
||||
if value == deselectOption {
|
||||
value = ""
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// orgNames is a helper function to turn a list of organizations into a list of
|
||||
// their names as strings.
|
||||
func orgNames(orgs []codersdk.Organization) []string {
|
||||
names := make([]string, 0, len(orgs))
|
||||
for _, org := range orgs {
|
||||
names = append(names, org.Name)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func (r *RootCmd) currentOrganization() *serpent.Command {
|
||||
func (r *RootCmd) showOrganization(orgContext *OrganizationContext) *serpent.Command {
|
||||
var (
|
||||
stringFormat func(orgs []codersdk.Organization) (string, error)
|
||||
client = new(codersdk.Client)
|
||||
@@ -226,8 +53,29 @@ func (r *RootCmd) currentOrganization() *serpent.Command {
|
||||
onlyID = false
|
||||
)
|
||||
cmd := &serpent.Command{
|
||||
Use: "show [current|me|uuid]",
|
||||
Short: "Show the organization, if no argument is given, the organization currently in use will be shown.",
|
||||
Use: "show [\"selected\"|\"me\"|uuid|org_name]",
|
||||
Short: "Show the organization. " +
|
||||
"Using \"selected\" will show the selected organization from the \"--org\" flag. " +
|
||||
"Using \"me\" will show all organizations you are a member of.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "coder org show selected",
|
||||
Command: "Shows the organizations selected with '--org=<org_name>'. " +
|
||||
"This organization is the organization used by the cli.",
|
||||
},
|
||||
Example{
|
||||
Description: "coder org show me",
|
||||
Command: "List of all organizations you are a member of.",
|
||||
},
|
||||
Example{
|
||||
Description: "coder org show developers",
|
||||
Command: "Show organization with name 'developers'",
|
||||
},
|
||||
Example{
|
||||
Description: "coder org show 90ee1875-3db5-43b3-828e-af3687522e43",
|
||||
Command: "Show organization with the given ID.",
|
||||
},
|
||||
),
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireRangeArgs(0, 1),
|
||||
@@ -242,7 +90,7 @@ func (r *RootCmd) currentOrganization() *serpent.Command {
|
||||
},
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
orgArg := "current"
|
||||
orgArg := "selected"
|
||||
if len(inv.Args) >= 1 {
|
||||
orgArg = inv.Args[0]
|
||||
}
|
||||
@@ -250,14 +98,14 @@ func (r *RootCmd) currentOrganization() *serpent.Command {
|
||||
var orgs []codersdk.Organization
|
||||
var err error
|
||||
switch strings.ToLower(orgArg) {
|
||||
case "current":
|
||||
case "selected":
|
||||
stringFormat = func(orgs []codersdk.Organization) (string, error) {
|
||||
if len(orgs) != 1 {
|
||||
return "", xerrors.Errorf("expected 1 organization, got %d", len(orgs))
|
||||
}
|
||||
return fmt.Sprintf("Current CLI Organization: %s (%s)\n", orgs[0].Name, orgs[0].ID.String()), nil
|
||||
}
|
||||
org, err := CurrentOrganization(r, inv, client)
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -12,11 +12,8 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/pty/ptytest"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestCurrentOrganization(t *testing.T) {
|
||||
@@ -32,8 +29,10 @@ func TestCurrentOrganization(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode([]codersdk.Organization{
|
||||
{
|
||||
ID: orgID,
|
||||
Name: "not-default",
|
||||
MinimalOrganization: codersdk.MinimalOrganization{
|
||||
ID: orgID,
|
||||
Name: "not-default",
|
||||
},
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
IsDefault: false,
|
||||
@@ -43,7 +42,7 @@ func TestCurrentOrganization(t *testing.T) {
|
||||
defer srv.Close()
|
||||
|
||||
client := codersdk.New(must(url.Parse(srv.URL)))
|
||||
inv, root := clitest.New(t, "organizations", "show", "current")
|
||||
inv, root := clitest.New(t, "organizations", "show", "selected")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
errC := make(chan error)
|
||||
@@ -53,98 +52,6 @@ func TestCurrentOrganization(t *testing.T) {
|
||||
require.NoError(t, <-errC)
|
||||
pty.ExpectMatch(orgID.String())
|
||||
})
|
||||
|
||||
t.Run("OnlyID", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ownerClient := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
// Owner is required to make orgs
|
||||
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
orgs := []string{"foo", "bar"}
|
||||
for _, orgName := range orgs {
|
||||
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
|
||||
Name: orgName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
inv, root := clitest.New(t, "organizations", "show", "--only-id")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- inv.Run()
|
||||
}()
|
||||
require.NoError(t, <-errC)
|
||||
pty.ExpectMatch(first.OrganizationID.String())
|
||||
})
|
||||
|
||||
t.Run("UsingFlag", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ownerClient := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
// Owner is required to make orgs
|
||||
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
orgs := map[string]codersdk.Organization{
|
||||
"foo": {},
|
||||
"bar": {},
|
||||
}
|
||||
for orgName := range orgs {
|
||||
org, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
|
||||
Name: orgName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
orgs[orgName] = org
|
||||
}
|
||||
|
||||
inv, root := clitest.New(t, "organizations", "show", "current", "--only-id", "-z=bar")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- inv.Run()
|
||||
}()
|
||||
require.NoError(t, <-errC)
|
||||
pty.ExpectMatch(orgs["bar"].ID.String())
|
||||
})
|
||||
}
|
||||
|
||||
func TestOrganizationSwitch(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Switch", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
ownerClient := coderdtest.New(t, nil)
|
||||
first := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
// Owner is required to make orgs
|
||||
client, _ := coderdtest.CreateAnotherUser(t, ownerClient, first.OrganizationID, rbac.RoleOwner())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
orgs := []string{"foo", "bar"}
|
||||
for _, orgName := range orgs {
|
||||
_, err := client.CreateOrganization(ctx, codersdk.CreateOrganizationRequest{
|
||||
Name: orgName,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
exp, err := client.OrganizationByName(ctx, "foo")
|
||||
require.NoError(t, err)
|
||||
|
||||
inv, root := clitest.New(t, "organizations", "set", "foo")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
pty := ptytest.New(t).Attach(inv)
|
||||
errC := make(chan error)
|
||||
go func() {
|
||||
errC <- inv.Run()
|
||||
}()
|
||||
require.NoError(t, <-errC)
|
||||
pty.ExpectMatch(exp.ID.String())
|
||||
})
|
||||
}
|
||||
|
||||
func must[V any](v V, err error) V {
|
||||
|
||||
@@ -18,8 +18,6 @@ func (r *RootCmd) createOrganization() *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "create <organization name>",
|
||||
Short: "Create a new organization.",
|
||||
// This action is currently irreversible, so it's hidden until we have a way to delete organizations.
|
||||
Hidden: true,
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireNArgs(1),
|
||||
@@ -30,6 +28,11 @@ func (r *RootCmd) createOrganization() *serpent.Command {
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
orgName := inv.Args[0]
|
||||
|
||||
err := codersdk.NameValid(orgName)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("organization name %q is invalid: %w", orgName, err)
|
||||
}
|
||||
|
||||
// This check is not perfect since not all users can read all organizations.
|
||||
// So ignore the error and if the org already exists, prevent the user
|
||||
// from creating it.
|
||||
@@ -38,7 +41,7 @@ func (r *RootCmd) createOrganization() *serpent.Command {
|
||||
return xerrors.Errorf("organization %q already exists", orgName)
|
||||
}
|
||||
|
||||
_, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Are you sure you want to create an organization with the name %s?\n%s",
|
||||
pretty.Sprint(cliui.DefaultStyles.Code, orgName),
|
||||
pretty.Sprint(cliui.BoldFmt(), "This action is irreversible."),
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizationMembers(orgContext *OrganizationContext) *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "members",
|
||||
Aliases: []string{"member"},
|
||||
Short: "Manage organization members",
|
||||
Children: []*serpent.Command{
|
||||
r.listOrganizationMembers(orgContext),
|
||||
r.assignOrganizationRoles(orgContext),
|
||||
r.addOrganizationMember(orgContext),
|
||||
r.removeOrganizationMember(orgContext),
|
||||
},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) removeOrganizationMember(orgContext *OrganizationContext) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "remove <username | user_id>",
|
||||
Short: "Remove a new member to the current organization",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := inv.Args[0]
|
||||
|
||||
err = client.DeleteOrganizationMember(ctx, organization.ID, user)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not remove member from organization %q: %w", organization.HumanName(), err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Organization member removed from %q\n", organization.HumanName())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) addOrganizationMember(orgContext *OrganizationContext) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "add <username | user_id>",
|
||||
Short: "Add a new member to the current organization",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
serpent.RequireNArgs(1),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := inv.Args[0]
|
||||
|
||||
_, err = client.PostOrganizationMember(ctx, organization.ID, user)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("could not add member to organization %q: %w", organization.HumanName(), err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Organization member added to %q\n", organization.HumanName())
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) assignOrganizationRoles(orgContext *OrganizationContext) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
|
||||
cmd := &serpent.Command{
|
||||
Use: "edit-roles <username | user_id> [roles...]",
|
||||
Aliases: []string{"edit-role"},
|
||||
Short: "Edit organization member's roles",
|
||||
Middleware: serpent.Chain(
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(inv.Args) < 1 {
|
||||
return xerrors.Errorf("user_id or username is required as the first argument")
|
||||
}
|
||||
userIdentifier := inv.Args[0]
|
||||
roles := inv.Args[1:]
|
||||
|
||||
member, err := client.UpdateOrganizationMemberRoles(ctx, organization.ID, userIdentifier, codersdk.UpdateRoles{
|
||||
Roles: roles,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update member roles: %w", err)
|
||||
}
|
||||
|
||||
updatedTo := make([]string, 0)
|
||||
for _, role := range member.Roles {
|
||||
updatedTo = append(updatedTo, role.String())
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Member roles updated to [%s]\n", strings.Join(updatedTo, ", "))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) listOrganizationMembers(orgContext *OrganizationContext) *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.TableFormat([]codersdk.OrganizationMemberWithUserData{}, []string{"username", "organization roles"}),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "list",
|
||||
Short: "List all organization members",
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
organization, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
res, err := client.OrganizationMembers(ctx, organization.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("fetch members: %w", err)
|
||||
}
|
||||
|
||||
out, err := formatter.Format(inv.Context(), res)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, out)
|
||||
return err
|
||||
},
|
||||
}
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/cli/clitest"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/testutil"
|
||||
)
|
||||
|
||||
func TestListOrganizationMembers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("OK", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ownerClient := coderdtest.New(t, &coderdtest.Options{})
|
||||
owner := coderdtest.CreateFirstUser(t, ownerClient)
|
||||
client, user := coderdtest.CreateAnotherUser(t, ownerClient, owner.OrganizationID, rbac.RoleUserAdmin())
|
||||
|
||||
ctx := testutil.Context(t, testutil.WaitMedium)
|
||||
inv, root := clitest.New(t, "organization", "members", "list", "-c", "user id,username,organization roles")
|
||||
clitest.SetupConfig(t, client, root)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
inv.Stdout = buf
|
||||
err := inv.WithContext(ctx).Run()
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, buf.String(), user.Username)
|
||||
require.Contains(t, buf.String(), owner.UserID.String())
|
||||
})
|
||||
}
|
||||
+331
-28
@@ -1,18 +1,22 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/coderd/util/slice"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizationRoles() *serpent.Command {
|
||||
func (r *RootCmd) organizationRoles(orgContext *OrganizationContext) *serpent.Command {
|
||||
cmd := &serpent.Command{
|
||||
Use: "roles",
|
||||
Short: "Manage organization roles.",
|
||||
@@ -20,36 +24,30 @@ func (r *RootCmd) organizationRoles() *serpent.Command {
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Hidden: true,
|
||||
Children: []*serpent.Command{
|
||||
r.showOrganizationRoles(),
|
||||
r.showOrganizationRoles(orgContext),
|
||||
r.editOrganizationRole(orgContext),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) showOrganizationRoles() *serpent.Command {
|
||||
func (r *RootCmd) showOrganizationRoles(orgContext *OrganizationContext) *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat([]assignableRolesTableRow{}, []string{"name", "display_name", "built_in", "site_permissions", "org_permissions", "user_permissions"}),
|
||||
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
|
||||
func(data any) (any, error) {
|
||||
input, ok := data.([]codersdk.AssignableRoles)
|
||||
inputs, ok := data.([]codersdk.AssignableRoles)
|
||||
if !ok {
|
||||
return nil, xerrors.Errorf("expected []codersdk.AssignableRoles got %T", data)
|
||||
}
|
||||
rows := make([]assignableRolesTableRow, 0, len(input))
|
||||
for _, role := range input {
|
||||
rows = append(rows, assignableRolesTableRow{
|
||||
Name: role.Name,
|
||||
DisplayName: role.DisplayName,
|
||||
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
|
||||
OrganizationPermissions: fmt.Sprintf("%d organizations", len(role.OrganizationPermissions)),
|
||||
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
|
||||
Assignable: role.Assignable,
|
||||
BuiltIn: role.BuiltIn,
|
||||
})
|
||||
|
||||
tableRows := make([]roleTableRow, 0)
|
||||
for _, input := range inputs {
|
||||
tableRows = append(tableRows, roleToTableView(input.Role))
|
||||
}
|
||||
return rows, nil
|
||||
|
||||
return tableRows, nil
|
||||
},
|
||||
),
|
||||
cliui.JSONFormat(),
|
||||
@@ -64,7 +62,7 @@ func (r *RootCmd) showOrganizationRoles() *serpent.Command {
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := CurrentOrganization(r, inv, client)
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -101,13 +99,318 @@ func (r *RootCmd) showOrganizationRoles() *serpent.Command {
|
||||
return cmd
|
||||
}
|
||||
|
||||
type assignableRolesTableRow struct {
|
||||
Name string `table:"name,default_sort"`
|
||||
DisplayName string `table:"display_name"`
|
||||
SitePermissions string ` table:"site_permissions"`
|
||||
// map[<org_id>] -> Permissions
|
||||
OrganizationPermissions string `table:"org_permissions"`
|
||||
UserPermissions string `table:"user_permissions"`
|
||||
Assignable bool `table:"assignable"`
|
||||
BuiltIn bool `table:"built_in"`
|
||||
func (r *RootCmd) editOrganizationRole(orgContext *OrganizationContext) *serpent.Command {
|
||||
formatter := cliui.NewOutputFormatter(
|
||||
cliui.ChangeFormatterData(
|
||||
cliui.TableFormat([]roleTableRow{}, []string{"name", "display name", "site permissions", "organization permissions", "user permissions"}),
|
||||
func(data any) (any, error) {
|
||||
typed, _ := data.(codersdk.Role)
|
||||
return []roleTableRow{roleToTableView(typed)}, nil
|
||||
},
|
||||
),
|
||||
cliui.JSONFormat(),
|
||||
)
|
||||
|
||||
var (
|
||||
dryRun bool
|
||||
jsonInput bool
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "edit <role_name>",
|
||||
Short: "Edit an organization custom role",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Run with an input.json file",
|
||||
Command: "coder roles edit --stdin < role.json",
|
||||
},
|
||||
),
|
||||
Options: []serpent.Option{
|
||||
cliui.SkipPromptOption(),
|
||||
{
|
||||
Name: "dry-run",
|
||||
Description: "Does all the work, but does not submit the final updated role.",
|
||||
Flag: "dry-run",
|
||||
Value: serpent.BoolOf(&dryRun),
|
||||
},
|
||||
{
|
||||
Name: "stdin",
|
||||
Description: "Reads stdin for the json role definition to upload.",
|
||||
Flag: "stdin",
|
||||
Value: serpent.BoolOf(&jsonInput),
|
||||
},
|
||||
},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireRangeArgs(0, 1),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
createNewRole := true
|
||||
var customRole codersdk.Role
|
||||
if jsonInput {
|
||||
// JSON Upload mode
|
||||
bytes, err := io.ReadAll(inv.Stdin)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading stdin: %w", err)
|
||||
}
|
||||
|
||||
err = json.Unmarshal(bytes, &customRole)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("parsing stdin json: %w", err)
|
||||
}
|
||||
|
||||
if customRole.Name == "" {
|
||||
arr := make([]json.RawMessage, 0)
|
||||
err = json.Unmarshal(bytes, &arr)
|
||||
if err == nil && len(arr) > 0 {
|
||||
return xerrors.Errorf("the input appears to be an array, only 1 role can be sent at a time")
|
||||
}
|
||||
return xerrors.Errorf("json input does not appear to be a valid role")
|
||||
}
|
||||
|
||||
existingRoles, err := client.ListOrganizationRoles(ctx, org.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("listing existing roles: %w", err)
|
||||
}
|
||||
for _, existingRole := range existingRoles {
|
||||
if strings.EqualFold(customRole.Name, existingRole.Name) {
|
||||
// Editing an existing role
|
||||
createNewRole = false
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if len(inv.Args) == 0 {
|
||||
return xerrors.Errorf("missing role name argument, usage: \"coder organizations roles edit <role_name>\"")
|
||||
}
|
||||
|
||||
interactiveRole, newRole, err := interactiveOrgRoleEdit(inv, org.ID, client)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("editing role: %w", err)
|
||||
}
|
||||
|
||||
customRole = *interactiveRole
|
||||
createNewRole = newRole
|
||||
|
||||
preview := fmt.Sprintf("permissions: %d site, %d org, %d user",
|
||||
len(customRole.SitePermissions), len(customRole.OrganizationPermissions), len(customRole.UserPermissions))
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Are you sure you wish to update the role? " + preview,
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("abort: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var updated codersdk.Role
|
||||
if dryRun {
|
||||
// Do not actually post
|
||||
updated = customRole
|
||||
} else {
|
||||
switch createNewRole {
|
||||
case true:
|
||||
updated, err = client.CreateOrganizationRole(ctx, customRole)
|
||||
default:
|
||||
updated, err = client.UpdateOrganizationRole(ctx, customRole)
|
||||
}
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patch role: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
output, err := formatter.Format(ctx, updated)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("formatting: %w", err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, output)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
||||
formatter.AttachOptions(&cmd.Options)
|
||||
return cmd
|
||||
}
|
||||
|
||||
func interactiveOrgRoleEdit(inv *serpent.Invocation, orgID uuid.UUID, client *codersdk.Client) (*codersdk.Role, bool, error) {
|
||||
newRole := false
|
||||
ctx := inv.Context()
|
||||
roles, err := client.ListOrganizationRoles(ctx, orgID)
|
||||
if err != nil {
|
||||
return nil, newRole, xerrors.Errorf("listing roles: %w", err)
|
||||
}
|
||||
|
||||
// Make sure the role actually exists first
|
||||
var originalRole codersdk.AssignableRoles
|
||||
for _, r := range roles {
|
||||
if strings.EqualFold(inv.Args[0], r.Name) {
|
||||
originalRole = r
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if originalRole.Name == "" {
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "No organization role exists with that name, do you want to create one?",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, newRole, xerrors.Errorf("abort: %w", err)
|
||||
}
|
||||
|
||||
originalRole.Role = codersdk.Role{
|
||||
Name: inv.Args[0],
|
||||
OrganizationID: orgID.String(),
|
||||
}
|
||||
newRole = true
|
||||
}
|
||||
|
||||
// Some checks since interactive mode is limited in what it currently sees
|
||||
if len(originalRole.SitePermissions) > 0 {
|
||||
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains site wide permissions")
|
||||
}
|
||||
|
||||
if len(originalRole.UserPermissions) > 0 {
|
||||
return nil, newRole, xerrors.Errorf("unable to edit role in interactive mode, it contains user permissions")
|
||||
}
|
||||
|
||||
role := &originalRole.Role
|
||||
allowedResources := []codersdk.RBACResource{
|
||||
codersdk.ResourceTemplate,
|
||||
codersdk.ResourceWorkspace,
|
||||
codersdk.ResourceUser,
|
||||
codersdk.ResourceGroup,
|
||||
}
|
||||
|
||||
const done = "Finish and submit changes"
|
||||
const abort = "Cancel changes"
|
||||
|
||||
// Now starts the role editing "game".
|
||||
customRoleLoop:
|
||||
for {
|
||||
selected, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Message: "Select which resources to edit permissions",
|
||||
Options: append(permissionPreviews(role, allowedResources), done, abort),
|
||||
})
|
||||
if err != nil {
|
||||
return role, newRole, xerrors.Errorf("selecting resource: %w", err)
|
||||
}
|
||||
switch selected {
|
||||
case done:
|
||||
break customRoleLoop
|
||||
case abort:
|
||||
return role, newRole, xerrors.Errorf("edit role %q aborted", role.Name)
|
||||
default:
|
||||
strs := strings.Split(selected, "::")
|
||||
resource := strings.TrimSpace(strs[0])
|
||||
|
||||
actions, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Message: fmt.Sprintf("Select actions to allow across the whole deployment for resources=%q", resource),
|
||||
Options: slice.ToStrings(codersdk.RBACResourceActions[codersdk.RBACResource(resource)]),
|
||||
Defaults: defaultActions(role, resource),
|
||||
})
|
||||
if err != nil {
|
||||
return role, newRole, xerrors.Errorf("selecting actions for resource %q: %w", resource, err)
|
||||
}
|
||||
applyOrgResourceActions(role, resource, actions)
|
||||
// back to resources!
|
||||
}
|
||||
}
|
||||
// This println is required because the prompt ends us on the same line as some text.
|
||||
_, _ = fmt.Println()
|
||||
|
||||
return role, newRole, nil
|
||||
}
|
||||
|
||||
func applyOrgResourceActions(role *codersdk.Role, resource string, actions []string) {
|
||||
if role.OrganizationPermissions == nil {
|
||||
role.OrganizationPermissions = make([]codersdk.Permission, 0)
|
||||
}
|
||||
|
||||
// Construct new site perms with only new perms for the resource
|
||||
keep := make([]codersdk.Permission, 0)
|
||||
for _, perm := range role.OrganizationPermissions {
|
||||
perm := perm
|
||||
if string(perm.ResourceType) != resource {
|
||||
keep = append(keep, perm)
|
||||
}
|
||||
}
|
||||
|
||||
// Add new perms
|
||||
for _, action := range actions {
|
||||
keep = append(keep, codersdk.Permission{
|
||||
Negate: false,
|
||||
ResourceType: codersdk.RBACResource(resource),
|
||||
Action: codersdk.RBACAction(action),
|
||||
})
|
||||
}
|
||||
|
||||
role.OrganizationPermissions = keep
|
||||
}
|
||||
|
||||
func defaultActions(role *codersdk.Role, resource string) []string {
|
||||
if role.OrganizationPermissions == nil {
|
||||
role.OrganizationPermissions = []codersdk.Permission{}
|
||||
}
|
||||
|
||||
defaults := make([]string, 0)
|
||||
for _, perm := range role.OrganizationPermissions {
|
||||
if string(perm.ResourceType) == resource {
|
||||
defaults = append(defaults, string(perm.Action))
|
||||
}
|
||||
}
|
||||
return defaults
|
||||
}
|
||||
|
||||
func permissionPreviews(role *codersdk.Role, resources []codersdk.RBACResource) []string {
|
||||
previews := make([]string, 0, len(resources))
|
||||
for _, resource := range resources {
|
||||
previews = append(previews, permissionPreview(role, resource))
|
||||
}
|
||||
return previews
|
||||
}
|
||||
|
||||
func permissionPreview(role *codersdk.Role, resource codersdk.RBACResource) string {
|
||||
if role.OrganizationPermissions == nil {
|
||||
role.OrganizationPermissions = []codersdk.Permission{}
|
||||
}
|
||||
|
||||
count := 0
|
||||
for _, perm := range role.OrganizationPermissions {
|
||||
if perm.ResourceType == resource {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s :: %d permissions", resource, count)
|
||||
}
|
||||
|
||||
func roleToTableView(role codersdk.Role) roleTableRow {
|
||||
return roleTableRow{
|
||||
Name: role.Name,
|
||||
DisplayName: role.DisplayName,
|
||||
OrganizationID: role.OrganizationID,
|
||||
SitePermissions: fmt.Sprintf("%d permissions", len(role.SitePermissions)),
|
||||
OrganizationPermissions: fmt.Sprintf("%d permissions", len(role.OrganizationPermissions)),
|
||||
UserPermissions: fmt.Sprintf("%d permissions", len(role.UserPermissions)),
|
||||
}
|
||||
}
|
||||
|
||||
type roleTableRow struct {
|
||||
Name string `table:"name,default_sort"`
|
||||
DisplayName string `table:"display name"`
|
||||
OrganizationID string `table:"organization id"`
|
||||
SitePermissions string ` table:"site permissions"`
|
||||
// map[<org_id>] -> Permissions
|
||||
OrganizationPermissions string `table:"organization permissions"`
|
||||
UserPermissions string `table:"user permissions"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (r *RootCmd) organizationSettings(orgContext *OrganizationContext) *serpent.Command {
|
||||
settings := []organizationSetting{
|
||||
{
|
||||
Name: "group-sync",
|
||||
Aliases: []string{"groupsync"},
|
||||
Short: "Group sync settings to sync groups from an IdP.",
|
||||
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
|
||||
var req codersdk.GroupSyncSettings
|
||||
err := json.Unmarshal(input, &req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshalling group sync settings: %w", err)
|
||||
}
|
||||
return cli.PatchGroupIDPSyncSettings(ctx, org.String(), req)
|
||||
},
|
||||
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
|
||||
return cli.GroupIDPSyncSettings(ctx, org.String())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "role-sync",
|
||||
Aliases: []string{"rolesync"},
|
||||
Short: "Role sync settings to sync organization roles from an IdP.",
|
||||
Patch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error) {
|
||||
var req codersdk.RoleSyncSettings
|
||||
err := json.Unmarshal(input, &req)
|
||||
if err != nil {
|
||||
return nil, xerrors.Errorf("unmarshalling role sync settings: %w", err)
|
||||
}
|
||||
return cli.PatchRoleIDPSyncSettings(ctx, org.String(), req)
|
||||
},
|
||||
Fetch: func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error) {
|
||||
return cli.RoleIDPSyncSettings(ctx, org.String())
|
||||
},
|
||||
},
|
||||
}
|
||||
cmd := &serpent.Command{
|
||||
Use: "settings",
|
||||
Short: "Manage organization settings.",
|
||||
Aliases: []string{"setting"},
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
r.printOrganizationSetting(orgContext, settings),
|
||||
r.setOrganizationSettings(orgContext, settings),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
type organizationSetting struct {
|
||||
Name string
|
||||
Aliases []string
|
||||
Short string
|
||||
Patch func(ctx context.Context, cli *codersdk.Client, org uuid.UUID, input json.RawMessage) (any, error)
|
||||
Fetch func(ctx context.Context, cli *codersdk.Client, org uuid.UUID) (any, error)
|
||||
}
|
||||
|
||||
func (r *RootCmd) setOrganizationSettings(orgContext *OrganizationContext, settings []organizationSetting) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "set",
|
||||
Short: "Update specified organization setting.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Update group sync settings.",
|
||||
Command: "coder organization settings set groupsync < input.json",
|
||||
},
|
||||
),
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
|
||||
for _, set := range settings {
|
||||
set := set
|
||||
patch := set.Patch
|
||||
cmd.Children = append(cmd.Children, &serpent.Command{
|
||||
Use: set.Name,
|
||||
Aliases: set.Aliases,
|
||||
Short: set.Short,
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Read in the json
|
||||
inputData, err := io.ReadAll(inv.Stdin)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("reading stdin: %w", err)
|
||||
}
|
||||
|
||||
output, err := patch(ctx, client, org.ID, inputData)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patching %q: %w", set.Name, err)
|
||||
}
|
||||
|
||||
settingJSON, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
var dst bytes.Buffer
|
||||
err = json.Indent(&dst, settingJSON, "", "\t")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, dst.String())
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func (r *RootCmd) printOrganizationSetting(orgContext *OrganizationContext, settings []organizationSetting) *serpent.Command {
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Use: "show",
|
||||
Short: "Outputs specified organization setting.",
|
||||
Long: FormatExamples(
|
||||
Example{
|
||||
Description: "Output group sync settings.",
|
||||
Command: "coder organization settings show groupsync",
|
||||
},
|
||||
),
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
}
|
||||
|
||||
for _, set := range settings {
|
||||
set := set
|
||||
fetch := set.Fetch
|
||||
cmd.Children = append(cmd.Children, &serpent.Command{
|
||||
Use: set.Name,
|
||||
Aliases: set.Aliases,
|
||||
Short: set.Short,
|
||||
Options: []serpent.Option{},
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(0),
|
||||
r.InitClient(client),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx := inv.Context()
|
||||
org, err := orgContext.Selected(inv, client)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
output, err := fetch(ctx, client, org.ID)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("patching %q: %w", set.Name, err)
|
||||
}
|
||||
|
||||
settingJSON, err := json.Marshal(output)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to marshal organization setting %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
var dst bytes.Buffer
|
||||
err = json.Indent(&dst, settingJSON, "", "\t")
|
||||
if err != nil {
|
||||
return xerrors.Errorf("failed to indent organization setting as json %s: %w", inv.Args[0], err)
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintln(inv.Stdout, dst.String())
|
||||
return err
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
@@ -127,3 +127,21 @@ func parseParameterMapFile(parameterFile string) (map[string]string, error) {
|
||||
}
|
||||
return parameterMap, nil
|
||||
}
|
||||
|
||||
// buildFlags contains options relating to troubleshooting provisioner jobs.
|
||||
type buildFlags struct {
|
||||
provisionerLogDebug bool
|
||||
}
|
||||
|
||||
func (bf *buildFlags) cliOptions() []serpent.Option {
|
||||
return []serpent.Option{
|
||||
{
|
||||
Flag: "provisioner-log-debug",
|
||||
Description: `Sets the provisioner log level to debug.
|
||||
This will print additional information about the build process.
|
||||
This is useful for troubleshooting build issues.`,
|
||||
Value: serpent.BoolOf(&bf.provisionerLogDebug),
|
||||
Hidden: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
+174
-12
@@ -2,27 +2,91 @@ package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
"tailscale.com/tailcfg"
|
||||
|
||||
"cdr.dev/slog"
|
||||
"cdr.dev/slog/sloggers/sloghuman"
|
||||
|
||||
"github.com/briandowns/spinner"
|
||||
|
||||
"github.com/coder/pretty"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/cli/cliutil"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/codersdk/healthsdk"
|
||||
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
type pingSummary struct {
|
||||
Workspace string `table:"workspace,nosort"`
|
||||
Total int `table:"total"`
|
||||
Successful int `table:"successful"`
|
||||
Min *time.Duration `table:"min"`
|
||||
Avg *time.Duration `table:"avg"`
|
||||
Max *time.Duration `table:"max"`
|
||||
Variance *time.Duration `table:"variance"`
|
||||
latencySum float64
|
||||
runningAvg float64
|
||||
m2 float64
|
||||
}
|
||||
|
||||
func (s *pingSummary) addResult(r *ipnstate.PingResult) {
|
||||
s.Total++
|
||||
if r == nil || r.Err != "" {
|
||||
return
|
||||
}
|
||||
s.Successful++
|
||||
if s.Min == nil || r.LatencySeconds < s.Min.Seconds() {
|
||||
s.Min = ptr.Ref(time.Duration(r.LatencySeconds * float64(time.Second)))
|
||||
}
|
||||
if s.Max == nil || r.LatencySeconds > s.Min.Seconds() {
|
||||
s.Max = ptr.Ref(time.Duration(r.LatencySeconds * float64(time.Second)))
|
||||
}
|
||||
s.latencySum += r.LatencySeconds
|
||||
|
||||
d := r.LatencySeconds - s.runningAvg
|
||||
s.runningAvg += d / float64(s.Successful)
|
||||
d2 := r.LatencySeconds - s.runningAvg
|
||||
s.m2 += d * d2
|
||||
}
|
||||
|
||||
// Write finalizes the summary and writes it
|
||||
func (s *pingSummary) Write(w io.Writer) {
|
||||
if s.Successful > 0 {
|
||||
s.Avg = ptr.Ref(time.Duration(s.latencySum / float64(s.Successful) * float64(time.Second)))
|
||||
}
|
||||
if s.Successful > 1 {
|
||||
s.Variance = ptr.Ref(time.Duration((s.m2 / float64(s.Successful-1)) * float64(time.Second)))
|
||||
}
|
||||
out, err := cliui.DisplayTable([]*pingSummary{s}, "", nil)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(w, "Failed to display ping summary: %v\n", err)
|
||||
return
|
||||
}
|
||||
width := len(strings.Split(out, "\n")[0])
|
||||
_, _ = fmt.Println(strings.Repeat("-", width))
|
||||
_, _ = fmt.Fprint(w, out)
|
||||
}
|
||||
|
||||
func (r *RootCmd) ping() *serpent.Command {
|
||||
var (
|
||||
pingNum int64
|
||||
pingTimeout time.Duration
|
||||
pingWait time.Duration
|
||||
pingNum int64
|
||||
pingTimeout time.Duration
|
||||
pingWait time.Duration
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
|
||||
client := new(codersdk.Client)
|
||||
@@ -33,11 +97,20 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
defer cancel()
|
||||
|
||||
spin := spinner.New(spinner.CharSets[5], 100*time.Millisecond)
|
||||
spin.Writer = inv.Stderr
|
||||
spin.Suffix = pretty.Sprint(cliui.DefaultStyles.Keyword, " Collecting diagnostics...")
|
||||
spin.Start()
|
||||
|
||||
notifyCtx, notifyCancel := inv.SignalNotifyContext(ctx, StopSignals...)
|
||||
defer notifyCancel()
|
||||
|
||||
workspaceName := inv.Args[0]
|
||||
_, workspaceAgent, err := getWorkspaceAndAgent(
|
||||
ctx, inv, client,
|
||||
@@ -58,18 +131,75 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
|
||||
opts.BlockEndpoints = true
|
||||
}
|
||||
conn, err := workspacesdk.New(client).DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
if !r.disableNetworkTelemetry {
|
||||
opts.EnableTelemetry = true
|
||||
}
|
||||
wsClient := workspacesdk.New(client)
|
||||
conn, err := wsClient.DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
derpMap := conn.DERPMap()
|
||||
_ = derpMap
|
||||
|
||||
diagCtx, diagCancel := context.WithTimeout(inv.Context(), 30*time.Second)
|
||||
defer diagCancel()
|
||||
diags := conn.GetPeerDiagnostics()
|
||||
|
||||
// Silent ping to determine whether we should show diags
|
||||
_, didP2p, _, _ := conn.Ping(ctx)
|
||||
|
||||
ni := conn.GetNetInfo()
|
||||
connDiags := cliui.ConnDiags{
|
||||
DisableDirect: r.disableDirect,
|
||||
LocalNetInfo: ni,
|
||||
Verbose: r.verbose,
|
||||
PingP2P: didP2p,
|
||||
TroubleshootingURL: appearanceConfig.DocsURL + "/networking/troubleshooting",
|
||||
}
|
||||
|
||||
awsRanges, err := cliutil.FetchAWSIPRanges(diagCtx, cliutil.AWSIPRangesURL)
|
||||
if err != nil {
|
||||
opts.Logger.Debug(inv.Context(), "failed to retrieve AWS IP ranges", slog.Error(err))
|
||||
}
|
||||
|
||||
connDiags.ClientIPIsAWS = isAWSIP(awsRanges, ni)
|
||||
|
||||
connInfo, err := wsClient.AgentConnectionInfoGeneric(diagCtx)
|
||||
if err != nil || connInfo.DERPMap == nil {
|
||||
return xerrors.Errorf("Failed to retrieve connection info from server: %w\n", err)
|
||||
}
|
||||
connDiags.ConnInfo = connInfo
|
||||
ifReport, err := healthsdk.RunInterfacesReport()
|
||||
if err == nil {
|
||||
connDiags.LocalInterfaces = &ifReport
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve local interfaces report: %v\n", err)
|
||||
}
|
||||
|
||||
agentNetcheck, err := conn.Netcheck(diagCtx)
|
||||
if err == nil {
|
||||
connDiags.AgentNetcheck = &agentNetcheck
|
||||
connDiags.AgentIPIsAWS = isAWSIP(awsRanges, agentNetcheck.NetInfo)
|
||||
} else {
|
||||
var sdkErr *codersdk.Error
|
||||
if errors.As(err, &sdkErr) && sdkErr.StatusCode() == http.StatusNotFound {
|
||||
_, _ = fmt.Fprint(inv.Stdout, "Could not generate full connection report as the workspace agent is outdated\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Failed to retrieve connection report from agent: %v\n", err)
|
||||
}
|
||||
}
|
||||
|
||||
spin.Stop()
|
||||
cliui.PeerDiagnostics(inv.Stderr, diags)
|
||||
connDiags.Write(inv.Stderr)
|
||||
results := &pingSummary{
|
||||
Workspace: workspaceName,
|
||||
}
|
||||
n := 0
|
||||
didP2p := false
|
||||
start := time.Now()
|
||||
pingLoop:
|
||||
for {
|
||||
if n > 0 {
|
||||
time.Sleep(pingWait)
|
||||
@@ -79,6 +209,7 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
|
||||
dur, p2p, pong, err := conn.Ping(ctx)
|
||||
cancel()
|
||||
results.addResult(pong)
|
||||
if err != nil {
|
||||
if xerrors.Is(err, context.DeadlineExceeded) {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "ping to %q timed out \n", workspaceName)
|
||||
@@ -134,12 +265,25 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
pretty.Sprint(cliui.DefaultStyles.DateTimeStamp, dur.String()),
|
||||
)
|
||||
|
||||
if n == int(pingNum) {
|
||||
diags := conn.GetPeerDiagnostics()
|
||||
cliui.PeerDiagnostics(inv.Stdout, diags)
|
||||
return nil
|
||||
select {
|
||||
case <-notifyCtx.Done():
|
||||
break pingLoop
|
||||
default:
|
||||
if n == int(pingNum) {
|
||||
break pingLoop
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if didP2p {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "✔ You are connected directly (p2p)\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stderr, "❗ You are connected via a DERP relay, not directly (p2p)\n%s#common-problems-with-direct-connections\n", connDiags.TroubleshootingURL)
|
||||
}
|
||||
|
||||
results.Write(inv.Stdout)
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -160,10 +304,28 @@ func (r *RootCmd) ping() *serpent.Command {
|
||||
{
|
||||
Flag: "num",
|
||||
FlagShorthand: "n",
|
||||
Default: "10",
|
||||
Description: "Specifies the number of pings to perform.",
|
||||
Description: "Specifies the number of pings to perform. By default, pings will continue until interrupted.",
|
||||
Value: serpent.Int64Of(&pingNum),
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func isAWSIP(awsRanges *cliutil.AWSIPRanges, ni *tailcfg.NetInfo) bool {
|
||||
if awsRanges == nil {
|
||||
return false
|
||||
}
|
||||
if ni.GlobalV4 != "" {
|
||||
ip, err := netip.ParseAddr(ni.GlobalV4)
|
||||
if err == nil && awsRanges.CheckIP(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if ni.GlobalV6 != "" {
|
||||
ip, err := netip.ParseAddr(ni.GlobalV6)
|
||||
if err == nil && awsRanges.CheckIP(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"io"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"tailscale.com/ipn/ipnstate"
|
||||
)
|
||||
|
||||
func TestBuildSummary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("Ok", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []*ipnstate.PingResult{
|
||||
{
|
||||
Err: "",
|
||||
LatencySeconds: 0.1,
|
||||
},
|
||||
{
|
||||
Err: "",
|
||||
LatencySeconds: 0.2,
|
||||
},
|
||||
{
|
||||
Err: "",
|
||||
LatencySeconds: 0.3,
|
||||
},
|
||||
{
|
||||
Err: "ping error",
|
||||
LatencySeconds: 0.4,
|
||||
},
|
||||
}
|
||||
|
||||
actual := pingSummary{
|
||||
Workspace: "test",
|
||||
}
|
||||
for _, r := range input {
|
||||
actual.addResult(r)
|
||||
}
|
||||
actual.Write(io.Discard)
|
||||
require.Equal(t, time.Duration(0.1*float64(time.Second)), *actual.Min)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Avg)
|
||||
require.Equal(t, time.Duration(0.3*float64(time.Second)), *actual.Max)
|
||||
require.Equal(t, time.Duration(0.009999999*float64(time.Second)), *actual.Variance)
|
||||
require.Equal(t, actual.Successful, 3)
|
||||
})
|
||||
|
||||
t.Run("One", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []*ipnstate.PingResult{
|
||||
{
|
||||
LatencySeconds: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
actual := &pingSummary{
|
||||
Workspace: "test",
|
||||
}
|
||||
for _, r := range input {
|
||||
actual.addResult(r)
|
||||
}
|
||||
actual.Write(io.Discard)
|
||||
require.Equal(t, actual.Successful, 1)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Min)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Avg)
|
||||
require.Equal(t, time.Duration(0.2*float64(time.Second)), *actual.Max)
|
||||
require.Nil(t, actual.Variance)
|
||||
})
|
||||
|
||||
t.Run("NoLatency", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
input := []*ipnstate.PingResult{
|
||||
{
|
||||
Err: "ping error",
|
||||
},
|
||||
{
|
||||
Err: "ping error",
|
||||
LatencySeconds: 0.2,
|
||||
},
|
||||
}
|
||||
|
||||
expected := &pingSummary{
|
||||
Workspace: "test",
|
||||
Total: 2,
|
||||
Successful: 0,
|
||||
Min: nil,
|
||||
Avg: nil,
|
||||
Max: nil,
|
||||
Variance: nil,
|
||||
latencySum: 0,
|
||||
runningAvg: 0,
|
||||
m2: 0,
|
||||
}
|
||||
|
||||
actual := &pingSummary{
|
||||
Workspace: "test",
|
||||
}
|
||||
for _, r := range input {
|
||||
actual.addResult(r)
|
||||
}
|
||||
actual.Write(io.Discard)
|
||||
require.Equal(t, expected, actual)
|
||||
})
|
||||
}
|
||||
@@ -66,7 +66,6 @@ func TestPing(t *testing.T) {
|
||||
})
|
||||
|
||||
pty.ExpectMatch("pong from " + workspace.Name)
|
||||
pty.ExpectMatch("✔ received remote agent data from Coder networking coordinator")
|
||||
cancel()
|
||||
<-cmdDone
|
||||
})
|
||||
|
||||
+8
-2
@@ -29,6 +29,7 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
tcpForwards []string // <port>:<port>
|
||||
udpForwards []string // <port>:<port>
|
||||
disableAutostart bool
|
||||
appearanceConfig codersdk.AppearanceConfig
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
@@ -60,6 +61,7 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(1),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
ctx, cancel := context.WithCancel(inv.Context())
|
||||
@@ -88,8 +90,9 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
}
|
||||
|
||||
err = cliui.Agent(ctx, inv.Stderr, workspaceAgent.ID, cliui.AgentOptions{
|
||||
Fetch: client.WorkspaceAgent,
|
||||
Wait: false,
|
||||
Fetch: client.WorkspaceAgent,
|
||||
Wait: false,
|
||||
DocsURL: appearanceConfig.DocsURL,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("await agent: %w", err)
|
||||
@@ -106,6 +109,9 @@ func (r *RootCmd) portForward() *serpent.Command {
|
||||
_, _ = fmt.Fprintln(inv.Stderr, "Direct connections disabled.")
|
||||
opts.BlockEndpoints = true
|
||||
}
|
||||
if !r.disableNetworkTelemetry {
|
||||
opts.EnableTelemetry = true
|
||||
}
|
||||
conn, err := workspacesdk.New(client).DialAgent(ctx, workspaceAgent.ID, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
+186
@@ -0,0 +1,186 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/cli/cliui"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/serpent"
|
||||
)
|
||||
|
||||
func (RootCmd) promptExample() *serpent.Command {
|
||||
promptCmd := func(use string, prompt func(inv *serpent.Invocation) error, options ...serpent.Option) *serpent.Command {
|
||||
return &serpent.Command{
|
||||
Use: use,
|
||||
Options: options,
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return prompt(inv)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
var useSearch bool
|
||||
useSearchOption := serpent.Option{
|
||||
Name: "search",
|
||||
Description: "Show the search.",
|
||||
Required: false,
|
||||
Flag: "search",
|
||||
Value: serpent.BoolOf(&useSearch),
|
||||
}
|
||||
cmd := &serpent.Command{
|
||||
Use: "prompt-example",
|
||||
Short: "Example of various prompt types used within coder cli.",
|
||||
Long: "Example of various prompt types used within coder cli. " +
|
||||
"This command exists to aid in adjusting visuals of command prompts.",
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
return inv.Command.HelpHandler(inv)
|
||||
},
|
||||
Children: []*serpent.Command{
|
||||
promptCmd("confirm", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Basic confirmation prompt.",
|
||||
Default: "yes",
|
||||
IsConfirm: true,
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", value)
|
||||
return err
|
||||
}),
|
||||
promptCmd("validation", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Input a string that starts with a capital letter.",
|
||||
Default: "",
|
||||
Secret: false,
|
||||
IsConfirm: false,
|
||||
Validate: func(s string) error {
|
||||
if len(s) == 0 {
|
||||
return xerrors.Errorf("an input string is required")
|
||||
}
|
||||
if strings.ToUpper(string(s[0])) != string(s[0]) {
|
||||
return xerrors.Errorf("input string must start with a capital letter")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n", value)
|
||||
return err
|
||||
}),
|
||||
promptCmd("secret", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: "Input a secret",
|
||||
Default: "",
|
||||
Secret: true,
|
||||
IsConfirm: false,
|
||||
Validate: func(s string) error {
|
||||
if len(s) == 0 {
|
||||
return xerrors.Errorf("an input string is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Your secret of length %d is safe with me\n", len(value))
|
||||
return err
|
||||
}),
|
||||
promptCmd("select", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Options: []string{
|
||||
"Blue", "Green", "Yellow", "Red", "Something else",
|
||||
},
|
||||
Default: "",
|
||||
Message: "Select your favorite color:",
|
||||
Size: 5,
|
||||
HideSearch: !useSearch,
|
||||
})
|
||||
if value == "Something else" {
|
||||
_, _ = fmt.Fprint(inv.Stdout, "I would have picked blue.\n")
|
||||
} else {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s is a nice color.\n", value)
|
||||
}
|
||||
return err
|
||||
}, useSearchOption),
|
||||
promptCmd("multiple", func(inv *serpent.Invocation) error {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "This command exists to test the behavior of multiple prompts. The survey library does not erase the original message prompt after.")
|
||||
thing, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Message: "Select a thing",
|
||||
Options: []string{
|
||||
"Car", "Bike", "Plane", "Boat", "Train",
|
||||
},
|
||||
Default: "Car",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
color, err := cliui.Select(inv, cliui.SelectOptions{
|
||||
Message: "Select a color",
|
||||
Options: []string{
|
||||
"Blue", "Green", "Yellow", "Red",
|
||||
},
|
||||
Default: "Blue",
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
properties, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Message: "Select properties",
|
||||
Options: []string{
|
||||
"Fast", "Cool", "Expensive", "New",
|
||||
},
|
||||
Defaults: []string{"Fast"},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "Your %s %s is awesome! Did you paint it %s?\n",
|
||||
strings.Join(properties, " "),
|
||||
thing,
|
||||
color,
|
||||
)
|
||||
return err
|
||||
}),
|
||||
promptCmd("multi-select", func(inv *serpent.Invocation) error {
|
||||
values, err := cliui.MultiSelect(inv, cliui.MultiSelectOptions{
|
||||
Message: "Select some things:",
|
||||
Options: []string{
|
||||
"Code", "Chair", "Whale", "Diamond", "Carrot",
|
||||
},
|
||||
Defaults: []string{"Code"},
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%q are nice choices.\n", strings.Join(values, ", "))
|
||||
return err
|
||||
}),
|
||||
promptCmd("rich-parameter", func(inv *serpent.Invocation) error {
|
||||
value, err := cliui.RichSelect(inv, cliui.RichSelectOptions{
|
||||
Options: []codersdk.TemplateVersionParameterOption{
|
||||
{
|
||||
Name: "Blue",
|
||||
Description: "Like the ocean.",
|
||||
Value: "blue",
|
||||
Icon: "/logo/blue.png",
|
||||
},
|
||||
{
|
||||
Name: "Red",
|
||||
Description: "Like a clown's nose.",
|
||||
Value: "red",
|
||||
Icon: "/logo/red.png",
|
||||
},
|
||||
{
|
||||
Name: "Yellow",
|
||||
Description: "Like a bumblebee. ",
|
||||
Value: "yellow",
|
||||
Icon: "/logo/yellow.png",
|
||||
},
|
||||
},
|
||||
Default: "blue",
|
||||
Size: 5,
|
||||
HideSearch: useSearch,
|
||||
})
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s is a good choice.\n", value.Name)
|
||||
return err
|
||||
}, useSearchOption),
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
+3
-1
@@ -13,6 +13,7 @@ import (
|
||||
)
|
||||
|
||||
func (r *RootCmd) rename() *serpent.Command {
|
||||
var appearanceConfig codersdk.AppearanceConfig
|
||||
client := new(codersdk.Client)
|
||||
cmd := &serpent.Command{
|
||||
Annotations: workspaceCommand,
|
||||
@@ -21,6 +22,7 @@ func (r *RootCmd) rename() *serpent.Command {
|
||||
Middleware: serpent.Chain(
|
||||
serpent.RequireNArgs(2),
|
||||
r.InitClient(client),
|
||||
initAppearance(client, &appearanceConfig),
|
||||
),
|
||||
Handler: func(inv *serpent.Invocation) error {
|
||||
workspace, err := namedWorkspace(inv.Context(), client, inv.Args[0])
|
||||
@@ -31,7 +33,7 @@ func (r *RootCmd) rename() *serpent.Command {
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "%s\n\n",
|
||||
pretty.Sprint(cliui.DefaultStyles.Wrap, "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(inv.Stdout, "See: %s\n\n", "https://coder.com/docs/coder-oss/latest/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls")
|
||||
_, _ = fmt.Fprintf(inv.Stdout, "See: %s%s\n\n", appearanceConfig.DocsURL, "/templates/resource-persistence#%EF%B8%8F-persistence-pitfalls")
|
||||
_, err = cliui.Prompt(inv, cliui.PromptOptions{
|
||||
Text: fmt.Sprintf("Type %q to confirm rename:", workspace.Name),
|
||||
Validate: func(s string) error {
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@ func TestRename(t *testing.T) {
|
||||
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, owner.OrganizationID, template.ID)
|
||||
workspace := coderdtest.CreateWorkspace(t, member, template.ID)
|
||||
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user