Compare commits

...

500 Commits

Author SHA1 Message Date
mbecker20
8c76b8487f alert responsive, better Server terminal disabled 2025-10-22 13:48:08 -07:00
mbecker20
2b32d9042a deploy 2.0.0-dev-72 2025-10-22 01:00:19 -07:00
mbecker20
dc48f1f2ca deploy 2.0.0-dev-71 2025-10-22 00:50:02 -07:00
mbecker20
8e7b7bdcf1 deploy 2.0.0-dev-70 2025-10-22 00:44:54 -07:00
mbecker20
f11d64f72e add 'init' param to make 'execute_terminal' in single call possible 2025-10-22 00:44:33 -07:00
mbecker20
2ffae85180 dashboard table section headers link to resources page 2025-10-22 00:03:12 -07:00
mbecker20
bd79d0f1e0 km ssh <SERVER> [COMMAND] -n [NAME] 2025-10-21 23:55:36 -07:00
mbecker20
e890b1f675 deploy 2.0.0-dev-69 2025-10-21 23:32:18 -07:00
mbecker20
3b7de25c30 Shift + X - Terminals, Shift + N - New (Resource, Terminal) 2025-10-21 16:11:27 -07:00
mbecker20
793bb99f31 nav to terminal on create 2025-10-21 16:00:50 -07:00
mbecker20
d465c9f273 deploy 2.0.0-dev-68 2025-10-21 15:51:38 -07:00
mbecker20
ce641a8974 terminal page 2025-10-21 15:51:18 -07:00
mbecker20
1b89ceb122 deploy 2.0.0-dev-67 2025-10-21 02:50:21 -07:00
mbecker20
2dbc011d26 remove unneeded log on client terminal disconnect 2025-10-21 02:33:19 -07:00
mbecker20
246da88ae1 deploy 2.0.0-dev-66 2025-10-21 02:29:12 -07:00
mbecker20
a8c16f64b1 km ssh 2025-10-21 02:28:42 -07:00
mbecker20
a5b711a348 stack tabs localstorage increment 2025-10-20 20:35:08 -07:00
mbecker20
9666e9ad83 Fix monitoring table with proper server version component 2025-10-20 03:01:07 -07:00
mbecker20
7479640c73 add hover information for mysterious server header icons 2025-10-20 02:53:18 -07:00
mbecker20
4823825035 give websocket indicator info on hover 2025-10-20 02:35:12 -07:00
mbecker20
23897a7acf clippy 2025-10-20 02:16:52 -07:00
mbecker20
20d5588b5c deploy 2.0.0-dev-65 2025-10-20 02:15:15 -07:00
mbecker20
f7e15ccde5 progress on terminals page 2025-10-20 02:14:51 -07:00
mbecker20
cf7623b1fc combine all resources / table view into dashboard 2025-10-20 01:40:27 -07:00
mbecker20
d3c464c05d start Terminals management page 2025-10-20 00:42:45 -07:00
mbecker20
5c9d416aa4 prog on docs update 2025-10-19 23:33:41 -07:00
mbecker20
aabcd88312 update connect-servers docs 2025-10-19 23:07:50 -07:00
mbecker20
9d2624c6bc clarify root directory in periphery config file 2025-10-19 23:07:19 -07:00
mbecker20
ee11fb0b6c clean up setup script 2025-10-19 23:07:02 -07:00
mbecker20
45adfbddd0 mounting custom CA 2025-10-19 23:06:48 -07:00
mbecker20
d26d035dc6 clean up docs intro 2025-10-19 22:03:17 -07:00
mbecker20
e673ba0adf deploy 2.0.0-dev-64 2025-10-19 21:48:15 -07:00
mbecker20
f876facfa7 improve git status message / failure propogation 2025-10-19 21:47:29 -07:00
mbecker20
3a47d57478 container class px-[1.2rem] 2025-10-19 20:31:40 -07:00
mbecker20
a707028277 responsive tweaks 2025-10-19 20:07:30 -07:00
mbecker20
0c6276c677 fix Resources / Containers mobile 2025-10-19 19:51:28 -07:00
mbecker20
fc9c6706f1 keep more descriptive settings header mobile 2025-10-19 13:24:44 -07:00
mbecker20
7674269ce9 fix user dropdown not showing username mobile 2025-10-19 13:11:34 -07:00
mbecker20
3b511c5adc improve server terminal mobile responsiveness 2025-10-19 13:00:30 -07:00
mbecker20
87221a10e9 fix mobile ContainerTerminal responsiveness 2025-10-19 12:56:11 -07:00
mbecker20
450cb6a148 fix stack config files mobile responsiveness 2025-10-19 12:46:51 -07:00
mbecker20
f252cefb21 responsive server docker tab 2025-10-19 12:37:26 -07:00
mbecker20
7855e9d688 run dkf 2025-10-19 12:30:59 -07:00
mbecker20
feb263c15f more type safe tabs 2025-10-19 12:27:55 -07:00
mbecker20
4f8d1c22cc rest of tabs also use mobile friendly 2025-10-19 12:11:11 -07:00
mbecker20
60bd47834e deploy 2.0.0-dev-63 2025-10-19 11:48:09 -07:00
mbecker20
4d632a6b61 improve resources mobile tabs responsiveness 2025-10-19 11:47:47 -07:00
mbecker20
381dd76723 deploy 2.0.0-dev-62 2025-10-19 01:37:10 -07:00
mbecker20
077e28a5fe fix ConfigList too wide on mobile 2025-10-19 01:36:50 -07:00
mbecker20
6b02aaed7d hide core pubkey copy if origin not https 2025-10-19 01:28:45 -07:00
mbecker20
e466944c05 improve mobile settings view 2025-10-19 01:24:41 -07:00
mbecker20
8ff94b7465 deploy 2.0.0-dev-61 2025-10-19 00:35:26 -07:00
mbecker20
b17df5ed7b show host public ip 2025-10-19 00:34:52 -07:00
mbecker20
207dc30206 cli is distroless, no shell / update-ca-certificates 2025-10-18 22:12:44 -07:00
mbecker20
c3eb386bdb fix copy entrypoint 2025-10-18 22:07:16 -07:00
mbecker20
4279e46892 deploy 2.0.0-dev-60 2025-10-18 12:59:19 -07:00
mbecker20
8d3d2fee12 use entrypoint scripts to make update-ca-certificates consistent when using custom CMD 2025-10-18 12:58:55 -07:00
mbecker20
1df36c4266 deploy 2.0.0-dev-59 2025-10-18 11:36:07 -07:00
mbecker20
36f7ad33c7 core and periphery images auto run update-ca-certificates on start, only need to mount in. 2025-10-18 11:35:45 -07:00
mbecker20
ec34b2c139 deploy 2.0.0-dev-58 2025-10-18 11:02:11 -07:00
mbecker20
d14c28d1f2 new otel instrumentation 2025-10-18 11:01:47 -07:00
mbecker20
68f7a0e9ce all info menu to top of settings 2025-10-18 00:45:59 -07:00
mbecker20
50f0376f0a Add Core title and public key to top of Settings 2025-10-18 00:01:41 -07:00
mbecker20
bbd53747ad fix km ps -h description 2025-10-17 17:17:18 -07:00
mbecker20
6a2adf1f83 tweak logs 2025-10-16 01:06:37 -07:00
mbecker20
128b15b94f deploy 2.0.0-dev-57 2025-10-16 00:59:46 -07:00
mbecker20
8d74b377b7 more otel refinements 2025-10-16 00:59:20 -07:00
mbecker20
d7e972e5c6 stack ui doesn't show project missing when deploying 2025-10-15 23:49:26 -07:00
mbecker20
e5cb4aac5a Fix: Webhook triggered checks linked repo branch for build, stack, sync 2025-10-15 18:06:43 -07:00
mbecker20
d0f62f8326 rework tracing events / improve opentelemetry output 2025-10-15 01:41:18 -07:00
mbecker20
47c4091a4b onboarding key uses recognizable key 2025-10-14 16:57:35 -07:00
mbecker20
973480e2b3 remove all the unnecessary instrument debug 2025-10-14 00:33:53 -07:00
mbecker20
b9e1cc87d2 remove instrument from validate_cancel_repo_build 2025-10-13 23:52:55 -07:00
mbecker20
05d20c8603 deploy 2.0.0-dev-56 2025-10-13 22:05:07 -07:00
mbecker20
fe2d68a001 fix config loading 2025-10-13 22:04:42 -07:00
mbecker20
26fd5b2a6d deploy 2.0.0-dev-55 2025-10-13 20:30:40 -07:00
mbecker20
76457bcb61 apply env / shell interpolation as *final* config loading stage, to include env vars. 2025-10-13 20:26:13 -07:00
mbecker20
ebd2c2238d bump deps 2025-10-13 19:51:05 -07:00
mbecker20
b7fc1bef7b refine default env 2025-10-13 13:53:12 -07:00
mbecker20
50b9f2e1bf deploy 2.0.0-dev-54 2025-10-13 13:06:23 -07:00
mbecker20
41ce86f6ab deploy 2.0.0-dev-53 2025-10-12 20:00:47 -07:00
mbecker20
7a21c01e52 tweak 2025-10-12 19:59:09 -07:00
mbecker20
e63e282510 small clean up 2025-10-12 19:56:15 -07:00
mbecker20
5456b36c18 deploy 2.0.0-dev-52 2025-10-12 13:55:39 -07:00
mbecker20
fcfb58a7e9 periphery with server disabled can initialize core public key file 2025-10-12 13:55:15 -07:00
mbecker20
2203004a74 move periphery in memory state to state.rs 2025-10-12 13:15:52 -07:00
mbecker20
996fb49823 periphery server_enabled /version route 2025-10-12 12:56:29 -07:00
mbecker20
35d22c77a2 Core add non authed /version route 2025-10-12 12:55:14 -07:00
mbecker20
44ab89600f Simpllify Option + Result into one encoding layer 2025-10-12 03:24:00 -07:00
mbecker20
0900e48cb8 ntfy / pushover url interpolation 2025-10-12 01:34:07 -07:00
mbecker20
c530a46a27 deploy 2.0.0-dev-51 2025-10-12 01:09:35 -07:00
mbecker20
f69c8db3ea pass through whether Periphery docker daemon connection is ok 2025-10-12 01:08:45 -07:00
mbecker20
48f2f651e1 periphery runs with logs if couldn't connect to docker daemon 2025-10-12 01:05:20 -07:00
mbecker20
bdb5b4185e rename some websocket fields 2025-10-12 00:28:55 -07:00
mbecker20
42a7b8c19b move connection queries to periphery_client 2025-10-12 00:08:59 -07:00
mbecker20
ded17e4840 more encoding refine 2025-10-12 00:05:16 -07:00
mbecker20
80fb1e6889 more on encoding 2025-10-11 14:11:07 -07:00
mbecker20
1dc861f538 fix periphery keys init when config.private_key is not explicitly defined. 2025-10-11 12:00:29 -07:00
mbecker20
3da63395fd fix EncodedOption docs 2025-10-10 00:32:05 -07:00
mbecker20
c40cbc4d77 deploy 2.0.0-dev-50 2025-10-09 23:40:42 -07:00
mbecker20
05e352e88c attach working 2025-10-09 23:28:27 -07:00
mbecker20
5884c09fb8 fix fe 2025-10-09 22:05:36 -07:00
mbecker20
f8add38043 backend for container attach 2025-10-09 21:53:26 -07:00
mbecker20
501f734e8b deploy 2.0.0-dev-49 2025-10-09 20:13:17 -07:00
mbecker20
de62732ac8 bump jwt lib 2025-10-09 20:12:51 -07:00
mbecker20
bfa61058cd remove github only managed repo webhooks feature. Not well implemented or documented, also provider specific. 2025-10-09 20:06:06 -07:00
mbecker20
72ca6d9910 deploy 2.0.0-dev-48 2025-10-09 19:36:47 -07:00
mbecker20
4d1ac32ad3 clippy 2025-10-09 18:24:50 -07:00
mbecker20
927e5959fa move encoded message schemas between core / periphery into periphery_client 2025-10-09 18:23:44 -07:00
mbecker20
37ccc6e1ef isolate encoding out of transport 2025-10-09 18:11:49 -07:00
mbecker20
deaa8754f3 slowly better ergonomics 2025-10-09 17:29:05 -07:00
mbecker20
dd8ac67c72 clippy and fmt 2025-10-09 16:26:06 -07:00
mbecker20
be4457c9cf deploy 2.0.0-dev-47 2025-10-09 15:41:06 -07:00
mbecker20
1868421815 strictly typed transport bytes encoding 2025-10-09 15:40:42 -07:00
mbecker20
366f7a12b4 Enumerated transport message types 2025-10-08 20:36:16 -07:00
mbecker20
75119370df standardize key rotation with wrapper 2025-10-08 17:45:45 -07:00
mbecker20
9e85b9d4c8 deploy 2.0.0-dev-46 2025-10-08 03:59:22 -07:00
mbecker20
8afbbf23dc deploy 2.0.0-dev-45 2025-10-08 02:48:24 -07:00
mbecker20
770a1116a1 fix: RotateCoreKeys also needs to store the new keys in mem 2025-10-08 02:48:01 -07:00
mbecker20
0b4aebbc24 periphery refresh panics if server_enabled, and core public key fails to parse. 2025-10-08 02:32:52 -07:00
mbecker20
f1696e26e4 deploy 2.0.0-dev-44 2025-10-08 02:11:38 -07:00
mbecker20
1a7b682301 RotateCoreKeys api 2025-10-08 02:11:17 -07:00
mbecker20
b0110b05aa deploy 2.0.0-dev-43 2025-10-08 00:02:34 -07:00
mbecker20
561b490f26 write files potentially containing secrets as 0600 2025-10-07 23:59:53 -07:00
mbecker20
cac1f0b42e align server standard and monitoring tables 2025-10-07 19:41:57 -07:00
mbecker20
28886fb304 fix typos 2025-10-07 14:32:00 -07:00
mbecker20
fb84d4cf7d deploy 2.0.0-dev-42 2025-10-07 01:59:04 -07:00
mbecker20
31e9624556 auto_rotate_keys config 2025-10-07 01:58:13 -07:00
mbecker20
3864bb7115 onboarding key expiry view 2025-10-07 01:16:28 -07:00
mbecker20
cea8601246 remove deleted server from onboarding key copy server 2025-10-07 00:44:36 -07:00
mbecker20
a546364bf3 deploy 2.0.0-dev-41 2025-10-07 00:29:33 -07:00
mbecker20
c8c62ea562 core public keys improve refresh 2025-10-07 00:27:47 -07:00
mbecker20
845e8780c7 improve server stats UI 2025-10-06 23:45:26 -07:00
mbecker20
db60347566 deploy 2.0.0-dev-40 2025-10-06 22:12:40 -07:00
mbecker20
c3ea0239d6 fix passkey support 2025-10-06 22:12:15 -07:00
mbecker20
e9d13449bf improve ws trait ergonomics 2025-10-06 20:02:06 -07:00
mbecker20
2daa92a639 working with safer transport message api 2025-10-06 19:16:03 -07:00
mbecker20
6473080078 deploy 2.0.0-dev-39 2025-10-06 03:14:15 -07:00
mbecker20
d3957f65dc schedule alert send before not after 2025-10-06 03:13:48 -07:00
mbecker20
cb34969f1e move skip label to be built into images 2025-10-06 03:07:11 -07:00
mbecker20
55a0a8cd05 deploy 2.0.0-dev-38 2025-10-06 02:38:42 -07:00
mbecker20
89f08372c6 CloseAlert 2025-10-06 02:38:16 -07:00
mbecker20
6a3ce2d426 config log some errors 2025-10-06 02:13:36 -07:00
mbecker20
4928378d46 fmt 2025-10-06 02:03:03 -07:00
mbecker20
eea222cfba simplify periphery config by removing option 2025-10-06 02:02:31 -07:00
mbecker20
6e9cc2dc77 deploy 2.0.0-dev-37 2025-10-06 01:40:24 -07:00
mbecker20
55d45084d0 comment 2025-10-06 01:40:00 -07:00
mbecker20
9657a44049 Improve config toml / yaml / json interpolation support 2025-10-06 01:38:48 -07:00
mbecker20
51fa9ae3c2 update setup script readme 2025-10-06 00:29:35 -07:00
mbecker20
5fd256444e improve the setup script 2025-10-05 23:53:21 -07:00
mbecker20
059716f178 deploy 2.0.0-dev-36 2025-10-05 18:23:43 -07:00
mbecker20
0bee1fe2c5 fix: connect and connect insecure are swapped 2025-10-05 18:20:29 -07:00
mbecker20
1e58c1a958 deploy 2.0.0-dev-35 2025-10-05 17:45:16 -07:00
mbecker20
ed1431db0a improve v1 downgrade 2025-10-05 17:43:46 -07:00
mbecker20
dc769ff159 document periphery_public_key 2025-10-05 17:39:11 -07:00
mbecker20
098f23ac4c configure Core -> Periphery insecure_tls 2025-10-05 17:36:07 -07:00
mbecker20
03f577d22f forgiving periphery public key parsing 2025-10-05 17:02:08 -07:00
mbecker20
95ca217362 deploy 2.0.0-dev-34 2025-10-05 16:52:33 -07:00
mbecker20
6d61045764 support KOMODO_PERIPHERY_PUBLIC_KEY 2025-10-05 16:52:00 -07:00
mbecker20
34e075eaf3 periphery support core_tls_insecure_skip_verify 2025-10-05 16:12:04 -07:00
mbecker20
232dc0bb4e deploy 2.0.0-dev-33 2025-10-05 14:59:23 -07:00
mbecker20
0cc0ee2aab load periphery_public_key 2025-10-05 14:58:52 -07:00
mbecker20
edebe925ff add km maintenance tasks aliases 2025-10-05 14:50:20 -07:00
mbecker20
5fd45bbc7b deploy 2.0.0-dev-32 2025-10-05 14:39:09 -07:00
mbecker20
0a490dadb2 rotation maintenance execution doesn't make individual updates 2025-10-05 14:38:45 -07:00
mbecker20
23847c15bc deploy 2.0.0-dev-31 2025-10-05 14:21:18 -07:00
mbecker20
0d238aee4f onboarding create_builder 2025-10-05 14:20:58 -07:00
mbecker20
98ad6cf5fa create update use uppercase 2025-10-05 13:41:42 -07:00
mbecker20
e35b81630b deploy 2.0.0-dev-30 2025-10-05 13:30:47 -07:00
mbecker20
1215852fe4 onboarding set Server tags 2025-10-05 13:24:38 -07:00
mbecker20
4164b76ff5 onboarded server needs to be enabled 2025-10-05 12:36:04 -07:00
mbecker20
26a9daffeb deploy 2.0.0-dev-29 2025-10-05 05:49:29 -07:00
mbecker20
8bb9f16e9b onboarding save copy server selection 2025-10-05 05:47:51 -07:00
mbecker20
b6eaf76497 Include templates in onboarding selector 2025-10-05 05:44:58 -07:00
mbecker20
073893da0e deploy 2.0.0-dev-28 2025-10-05 05:18:48 -07:00
mbecker20
e71547f1c2 configure server onboarding key 2025-10-05 05:17:56 -07:00
mbecker20
1991627990 move periphery public key to Server info (keep it out of resource sync) 2025-10-05 04:18:59 -07:00
mbecker20
3434d827a3 deploy 2.0.0-dev-27 2025-10-05 02:57:58 -07:00
mbecker20
1ef8b9878a rotate all server keys task 2025-10-05 02:57:27 -07:00
mbecker20
07ddaa8377 tweak 2025-10-05 01:41:44 -07:00
mbecker20
142c08cde4 deploy 2.0.0-dev-26 2025-10-05 01:19:21 -07:00
mbecker20
1aa1422faa periphery private key rotation 2025-10-05 01:18:56 -07:00
mbecker20
1394e8a6b1 Rotate Server private keys 2025-10-05 00:54:56 -07:00
mbecker20
420ee10211 tweaks 2025-10-04 23:59:14 -07:00
mbecker20
e918461dc5 refine onboarding key 2025-10-04 23:36:37 -07:00
mbecker20
4dc9ca27be refactor Periphery onboarding 2025-10-04 16:43:02 -07:00
mbecker20
f49b186f2f consolidate periphery docker apis into single mod 2025-10-04 16:17:32 -07:00
mbecker20
6e039b41f1 deploy 2.0.0-dev-25 2025-10-03 17:51:46 -07:00
mbecker20
e7cd77b022 tweaks 2025-10-03 17:06:14 -07:00
mbecker20
556cbd04c7 server onboarding flow using onboarding key 2025-10-03 17:01:58 -07:00
mbecker20
4e3d181466 default documented setup now uses Periphery -> Core setup 2025-10-03 12:55:06 -07:00
mbecker20
5d4326f46f NOT_FOUND if server not found 2025-10-03 03:17:37 -07:00
mbecker20
4bb486ad0a deploy 2.0.0-dev-24 2025-10-03 02:30:20 -07:00
mbecker20
d29c5112d8 Confirm server public key flow 2025-10-03 02:29:53 -07:00
mbecker20
d41315b8a4 don't navigate to /login for network errors 2025-10-03 01:58:23 -07:00
mbecker20
847404388c deploy 2.0.0-dev-23 2025-10-03 00:48:11 -07:00
mbecker20
eef8ec59b8 deploy 2.0.0-dev-22 2025-10-03 00:19:43 -07:00
mbecker20
9eb32f9ff5 store attempted public keys 2025-10-03 00:13:55 -07:00
mbecker20
859bfe67ef Improve Core side connection handling and fix Periphery -> Core error report 2025-10-02 23:03:58 -07:00
mbecker20
21ea469cd4 add login message 2 sec timeout 2025-10-02 16:00:45 -07:00
mbecker20
7fb902b892 deploy 2.0.0-dev-21 2025-10-02 03:12:59 -07:00
mbecker20
c9c4ac47ee fix clippy 2025-10-02 02:34:23 -07:00
mbecker20
f228cd31f3 deploy 2.0.0-dev-20 2025-10-02 02:33:33 -07:00
mbecker20
4feecb4b97 write key pem files by default when not otherwise provided. 2025-10-02 02:32:13 -07:00
mbecker20
e2680d0942 fix deploy 2025-10-01 21:35:27 -07:00
mbecker20
7422c0730d deploy 2.0.0-dev-19 2025-10-01 21:27:59 -07:00
mbecker20
37ac0dc7e3 update deploy 2025-10-01 21:17:43 -07:00
mbecker20
dccaca1df4 make sure not a config file before include as compose file 2025-10-01 20:32:52 -07:00
mbecker20
886aea4c36 deploy 2.0.0-dev-18 2025-10-01 19:48:40 -07:00
mbecker20
cbca070bae load keys from files 2025-10-01 19:41:32 -07:00
mbecker20
b4bdd401f6 fix unneeded base64 prefix 2025-10-01 02:36:26 -07:00
mbecker20
e546166240 use pkcs8 and spki for private / public key encoding, matching openssl 2025-10-01 02:25:41 -07:00
mbecker20
21689ce0ad periphery support same key gen functions 2025-09-29 23:32:47 -07:00
mbecker20
941787db64 slack client 0.5.0 2025-09-29 12:38:39 -07:00
mbecker20
d4b1aacac3 comment out 2025-09-29 02:21:07 -07:00
mbecker20
30f89461bf deploy 2.0.0-dev-17 2025-09-29 00:57:19 -07:00
mbecker20
a42d1397e9 back to bullseye (for max GLIBC compatibility) 2025-09-29 00:56:19 -07:00
mbecker20
b29313c28f deploy 2.0.0-dev-16 2025-09-29 00:47:17 -07:00
mbecker20
08a246a90c bullseye -> trixie 2025-09-29 00:46:51 -07:00
mbecker20
1a08df28d0 docs and config clean up 2025-09-29 00:06:35 -07:00
mbecker20
a226ffc256 fix json config load from interpolated 2025-09-28 23:20:04 -07:00
mbecker20
b385ee5ec3 start on docs update 2025-09-28 22:59:58 -07:00
mbecker20
c78c34357d remove unnecessary connected to core websocket log 2025-09-28 18:14:57 -07:00
mbecker20
4b7c692f00 deploy 2.0.0-dev-15 2025-09-28 18:02:18 -07:00
mbecker20
1ac98a096e bump async timing util to 1.1.0 to support for timelengths 2025-09-28 17:57:12 -07:00
mbecker20
281a2dc1ce first server configuration works with Periphery -> Core 2025-09-28 14:39:11 -07:00
mbecker20
0fe91378a6 tweak key gen output 2025-09-28 14:12:41 -07:00
mbecker20
11e76d1cf2 deploy 2.0.0-dev-14 2025-09-28 13:10:00 -07:00
mbecker20
a3bcd71105 simplify cache refresh with single periphery call 2025-09-28 13:05:45 -07:00
mbecker20
3ecc56dd76 clean up crypto provider install 2025-09-27 21:40:20 -07:00
mbecker20
7239cbb19b remove extra install crypto provider 2025-09-27 19:37:50 -07:00
mbecker20
a0540f7011 deploy 2.0.0-dev-13 2025-09-27 16:54:00 -07:00
mbecker20
37aea7605e gen types 2025-09-27 14:33:14 -07:00
mbecker20
78be913541 fix stuff after main rebase 2025-09-27 14:26:58 -07:00
mbecker20
c34f5ebf49 update config and compose envs 2025-09-27 14:23:49 -07:00
mbecker20
e5822cefb8 clean up socket handling 2025-09-27 14:23:49 -07:00
mbecker20
4baab194cf centralize the terminal stuff 2025-09-27 14:23:49 -07:00
mbecker20
a896583da6 deploy 2.0.0-dev-12 2025-09-27 14:23:49 -07:00
mbecker20
7b2674c38b deploy 2.0.0-dev-11 2025-09-27 14:23:42 -07:00
mbecker20
d1e32989e3 allow any number of simultaneous inbound / outbound connections (to different Cores) 2025-09-27 14:23:36 -07:00
mbecker20
e802bb3882 periphery support multiple core_public_keys 2025-09-27 14:23:36 -07:00
mbecker20
27a38b1bf5 periphery support multiple simultaneous core connections 2025-09-27 14:23:36 -07:00
mbecker20
2bc8a754be clean up passkey login 2025-09-27 14:23:36 -07:00
mbecker20
7a2a54bec1 dev-10 2025-09-27 14:23:36 -07:00
mbecker20
6a15150d59 don't cleanup server type builders 2025-09-27 14:23:31 -07:00
mbecker20
1b1dca76da deploy 2.0.0-dev-9 2025-09-27 14:23:31 -07:00
mbecker20
a032f0f4ff move system info to server cache 2025-09-27 14:23:25 -07:00
mbecker20
2749d49435 Core -> Periphery connection prefers noise handshake if 'core_public_key' is set 2025-09-27 14:23:25 -07:00
mbecker20
d88e42ef2d add specific server passkey support back 2025-09-27 14:23:25 -07:00
mbecker20
a370e7d121 support passkey auth for Core -> Periphery connection to remove the breaking change 2025-09-27 14:23:25 -07:00
mbecker20
d139ad2b3d always fallback to core config 'periphery_public_key' 2025-09-27 14:23:25 -07:00
mbecker20
8d2d180398 deploy 2.0.0-dev-8 2025-09-27 14:22:48 -07:00
mbecker20
37ca4ca986 fix server update hang 2025-09-27 14:22:42 -07:00
mbecker20
33e73b8543 use warn log 2025-09-27 14:22:42 -07:00
mbecker20
cf6e36e90c periphery server avoid auth fail log spam 2025-09-27 14:22:42 -07:00
mbecker20
9eb8b32f4a create and delete connections on demand 2025-09-27 14:22:42 -07:00
mbecker20
b400add6f1 deploy 2.0.0-dev-7 2025-09-27 14:22:41 -07:00
mbecker20
24adb89d25 execute container exec waits a bit for terminal to init before sending command 2025-09-27 14:22:36 -07:00
mbecker20
4674b2badb deploy 2.0.0-dev-6 2025-09-27 14:22:36 -07:00
mbecker20
65d1a69cb9 Mount ExecuteContainerExec periphery api 2025-09-27 14:22:27 -07:00
mbecker20
0da5718991 store connection channels under the connection 2025-09-27 14:22:27 -07:00
mbecker20
6b26cd120c simplify most of periphery client into bin/core 2025-09-27 14:22:27 -07:00
mbecker20
28e1bb19a4 deploy 2.0.0-dev-5 2025-09-27 14:22:27 -07:00
mbecker20
166107ac07 bail_if_not_connected 2025-09-27 14:22:21 -07:00
mbecker20
d77201880f dashboard Active include GlobalAutoUpdate 2025-09-27 14:22:21 -07:00
mbecker20
1d7629e9b2 Update server address description and placeholders 2025-09-27 14:22:21 -07:00
mbecker20
198f690ca5 Got invalid public key: {public_key} 2025-09-27 14:22:21 -07:00
mbecker20
531c79a144 deploy 2.0.0-dev-4 2025-09-27 14:22:21 -07:00
mbecker20
d685862713 improve Core - Periphery auth error messages 2025-09-27 14:22:09 -07:00
mbecker20
af0f245b5b deploy 2.0.0-dev-3 2025-09-27 14:22:09 -07:00
mbecker20
cba36861b7 deploy 2.0.0-dev-2 2025-09-27 14:22:02 -07:00
mbecker20
2c2c1d47b4 dev-2 2025-09-27 14:22:02 -07:00
mbecker20
3a6b997241 Json and JsonPretty formatting 2025-09-27 14:21:54 -07:00
mbecker20
7122f79b9d add -f json option to key utils (for use with jquery etc. 2025-09-27 14:21:54 -07:00
mbecker20
9bcee8122b tweak 2025-09-27 14:21:54 -07:00
mbecker20
a49c98946e add copy pubkeys 2025-09-27 14:21:54 -07:00
mbecker20
7d222a7241 dev-1 2025-09-27 14:21:54 -07:00
mbecker20
33501dac3e fix Core -> Periphery reconnection 2025-09-27 14:21:44 -07:00
mbecker20
4675dfa736 improve the logging 2025-09-27 14:21:44 -07:00
mbecker20
0be51dc784 move core connection handlers into core binary 2025-09-27 14:21:44 -07:00
mbecker20
52453d1320 set default allowed periphery public key 2025-09-27 14:21:44 -07:00
mbecker20
25da97ac1a basic configure auth 2025-09-27 14:21:44 -07:00
mbecker20
02db5a11d3 pipe through core side private / public key handling 2025-09-27 14:21:44 -07:00
mbecker20
89a5272246 rename passkey -> private_key 2025-09-27 14:21:44 -07:00
mbecker20
ae51ea1ad6 Copy core public key 2025-09-27 14:21:44 -07:00
mbecker20
3bdb4bea16 Core includes public key in CoreInfo 2025-09-27 14:21:44 -07:00
mbecker20
677bb14b5d auth forward error 2025-09-27 14:21:44 -07:00
mbecker20
6700700a80 clean up websocket handlers with many params 2025-09-27 14:21:44 -07:00
mbecker20
996d4aa129 standardize server size header identifier extraction 2025-09-27 14:21:44 -07:00
mbecker20
75894a7282 wire through private keys 2025-09-27 14:21:44 -07:00
mbecker20
2a065edcf1 avoid looping periphery client error 2025-09-27 14:21:44 -07:00
mbecker20
6f3703acfb periphery client makes more sense 2025-09-27 14:21:44 -07:00
mbecker20
59e989ecdf noise library and cli key utilities 2025-09-27 14:21:44 -07:00
mbecker20
951ff34a9e abstract websocket handling implementations on both sides 2025-09-27 14:21:12 -07:00
mbecker20
2d83105500 clean up 2025-09-27 14:21:12 -07:00
mbecker20
3d455f5142 implement noise auth basic 2025-09-27 14:21:12 -07:00
mbecker20
01de8c4a9b use standardized websocket wrappers / traits 2025-09-27 14:21:12 -07:00
mbecker20
d5de338561 outbound connection mode working 2025-09-27 14:21:12 -07:00
mbecker20
58c1afb8ef add login draft for transport 2025-09-27 14:21:12 -07:00
mbecker20
230f357b5a everything over ws working 2025-09-27 14:21:12 -07:00
mbecker20
991c95fff0 execute basically working, still need to clear the response channel upon completion 2025-09-27 14:21:12 -07:00
mbecker20
f6243fe6b1 more cleanup 2025-09-27 14:21:12 -07:00
mbecker20
9feeccba6e container terminal over connection 2025-09-27 14:21:12 -07:00
mbecker20
673c7f3a6b multiplex requests + terminal over single WS 2025-09-27 14:21:12 -07:00
mbecker20
39f900d651 standardize and consolidate logic in transport lib 2025-09-27 14:21:12 -07:00
mbecker20
8a06a0d6ce is work 2025-09-27 14:21:12 -07:00
mbecker20
7789ee4f4a prog 2025-09-27 14:21:12 -07:00
mbecker20
0472b6a7f7 fix after 1.19.4 2025-09-27 14:21:12 -07:00
mbecker20
d1d2227d36 prog 2025-09-27 14:21:11 -07:00
mbecker20
cea7c5fc5e prog on ws connect 2025-09-27 14:21:11 -07:00
Maxwell Becker
34a9f8eb9e 1.19.5 (#846)
* start 1.19.5

* deploy 1.19.5-dev-1

* avoid execute_and_poll error when update is already complete or has no id

* improve image tagging customization

* 1.19.5 release
2025-09-27 13:29:16 -07:00
mbecker20
494d01aeed RequireAuth redirect when no jwt 2025-09-14 14:05:43 -07:00
mbecker20
084e2fec23 1.19.4 2025-09-14 12:32:58 -07:00
Maxwell Becker
98d72fc908 1.19.4 (#812)
* start 1.19.4

* deploy 1.19.4-dev-1

* try smaller binaries with cargo strip

* deploy 1.19.4-dev-2

* smaller binaries with cargo strip

* Fix Submit Dialog Button Behavior with 500 Errors on Duplicate Names (#819)

* Implement enhanced error handling and messaging for resource creation

* Implement improved error handling for resource creation across alerter, build, and sync

* Implement error handling improvements for resource copying and validation feedback

* Adjust error handling for resource creation to distinguish validation errors from unexpected system errors

* Refactor resource creation error handling by removing redundant match statements and simplifying the error propagation in multiple API modules.

* fmt

* bump indexmap

* fix account selector showing empty when account no longer found

* clean up theme logic, ensure monaco and others get up to date current theme

* enforce disable_non_admin_create for tags. Clean up status code responses

* update server cache concurrency controller

* deploy 1.19.4-dev-3

* Allow signing in by pressing enter (#830)

* Improve dialog overflow handling to prevent clipping of content (#828)

* Add Email notification entry to community.md (#824)

* Add clickable file path to show/hide file contents in StackInfo (#827)

* add clickable file path to show/hide file contents in StackInfo

Also added CopyButton due to the new functionality making the file path not selectable.

* Move clicking interaction to CardHeader

* Avoid sync edge cases of having toggle show function capturing showContents from outside

Co-authored-by: Maxwell Becker <49575486+mbecker20@users.noreply.github.com>

* Format previous change

* Add `default_show_contents` to `handleToggleShow`

---------

Co-authored-by: Maxwell Becker <49575486+mbecker20@users.noreply.github.com>

* deploy 1.19.4-dev-4

* avoid stake info ShowHideButton double toggle

* Allow multiple simultaneous Action runs for use with Args

* deploy 1.19.4-dev-5

* feat: persist all table sorting states including unsorted (#832)

- Always save sorting state to localStorage, even when empty/unsorted
- Fixes issue where 'unsorted' state was not persisted across page reloads
- Ensures consistent and predictable sorting behavior for all DataTable components

* autofocus on login username field (#837)

* Fix unnecessary auth queries flooding console on login page (#842)

* Refactor authentication error handling to use serror::Result and status codes

* Enable user query only when JWT is present

* Enable query execution in useRead only if JWT is present

* Revert backend auth changes - keep PR focused on frontend only

* Fix unnecessary API queries to unreachable servers flooding console (#843)

* Implement server availability checks in various components

* Refactor server availability check to ensure only healthy servers are identified

* cargo fmt

* fmt

* Auth error handling with status codes (#841)

* Refactor authentication error handling to use serror::Result and status codes

* Refactor error messages

* Refactor authentication error handling to include status codes and improve error messages

* clean up

* clean

* fmt

* invalid user id also UNAUTHORIZED

* deploy 1.19.4-dev-6

* deploy 1.19.4-dev-7

---------

Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
Co-authored-by: Guten <ywzhaifei@gmail.com>
Co-authored-by: Paulo Roberto Albuquerque <paulora2405@gmail.com>
Co-authored-by: Lorenzo Farnararo <2814802+baldarn@users.noreply.github.com>
2025-09-14 12:32:06 -07:00
azrikahar
20ac04fae5 fix navigation link to users page via omnibar (#838) 2025-09-11 10:04:45 -07:00
Maxwell Becker
a65fd4dca7 1.19.3 (#792)
* start. 1.19.3

* deploy 1.19.3-dev-1

* repo state from db includes BuildRepo success

* clean up version mismatch text

* feat(containers): debounced search input and added filter by server name (#796)

* Fix cleaning Alerter resource whitelist / blacklist on resource delete re #581

* fmt

* Fix signup button not working correctly (#801)

* Improve route protection and authentication flow (#798)

* Improve route protection and authentication flow

* Cleanup

* fix: inconsistent behaviour of new resource create button (#800)

* fix monaco crashing with absolute path config files

* deploy 1.19.3-dev-2

* proofread config

* Fix #427

* deploy 1.19.3-dev-3

* poll logs use println

* Sync: Only show commit / execute when viewing pending tab

* Improve sync UX

* deploy 1.19.3-dev-4

* bold link

* remove claims about database resource usage.

* 1.19.3

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Antonio Sarro <tech@antoniosarro.dev>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
2025-09-05 13:41:58 -07:00
mbecker20
0873104b5a fmt 2025-08-31 19:13:14 -07:00
Maxwell Becker
9a7b6ebd51 1.19.2 (#764)
* 1.19.2-dev-0

* deploy 1.19.2-dev-1

* Add option to make run command detachable (#766)

* improve missing files log to include the missing paths

* bump mungos for urlencoding mongo creds

* Update permissioning.md - typo: "priviledges" -> "privileges" (#770)

* Add support for monaco-yaml and docker compose spec validatiaon (#772)

* deploy 1.19.2-dev-2

* on delete user, remove from all user groups

* fix Google login issues around `picture`

* unsafe_unsanitized_startup_config

* improve git provider support re #355

* should fix #468

* should fix exit code re #597

* deploy 1.19.2-dev-3

* fix container ports sorting (#776)

* missing serde default

* deploy 1.19.2-dev-4

* ensure git tokens trimmed in remote url

* Add link to Authentik support docs

* Fix incorrect commit branch when using linked repo re #634

* Better display container port ranges (#786)

* ensure build and sync also commit to correct branch. re #634

* deploy 1.19.2-dev-5

* Improve login form (#788)

* Use proper form for login, add autocomplete and names to input fields

* Do not return null if loading

* Remove unused function

* Cleanup and streamline

* improve login screen flash on reload

* first builder given same name as first server

* 1.19.2

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Brian Bradley <brian.bradley.p@gmail.com>
Co-authored-by: Ravi Wolter-Krishan <rkn@gedikas.net>
Co-authored-by: Christopher Hoage <iam@chrishoage.com>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
2025-08-31 19:08:45 -07:00
mbecker20
a4153fa28b fix UI showing Redeploy when its actually None 2025-08-24 14:30:43 -07:00
Maxwell Becker
e732da3b05 1.19.1 (#740)
* start 1.19.1

* deploy 1.19.1-dev-1

* Global Auto Update rustdoc

* support stack additional files

* deploy 1.19.1-dev-2

* Fe support additional file language detection

* fix tsc

* Fix: Example code blocks got interpreted as rust code, leading to compilation errors (#743)

* Enhanced Server Stats Dashboard with Performance Optimizations (#746)

* Improve the layout of server mini stats in the dashboard.

- Server stats and tags made siblings for clearer responsibilities
- Changed margin to padding
- Unreachable indicator made into an overlay of the stats

* feat: optimize dashboard server stats with lazy loading and smart server availability checks

- Add enabled prop to ServerStatsMini for conditional data fetching
- Implement server availability check (only fetch stats for Ok servers, not NotOk/Disabled)
- Prevent 500 errors by avoiding API calls to offline servers
- Increase polling interval from 10s to 15s and add 5s stale time
- Add useMemo for expensive calculations to reduce re-renders
- Add conditional overlay rendering for unreachable servers
- Only render stats when showServerStats preference is enabled

* fix: show disabled servers with overlay instead of hiding component

- Maintain consistent layout by showing disabled state overlay
- Prevent UX inconsistency where disabled servers disappeared entirely

* fix: show button height

* feat: add enhance card animations

* cleanup

* gen types

* deploy 1.19.1-dev-3

* add .ini

* deploy 1.19.1-dev-4

* simple configure action args as JSON

* server enabled actually defaults false

* SendAlert via Action / CLI

* fix clippy if let string

* deploy 1.19.1-dev-5

* improve cli ergonomics

* gen types and fix responses formatting

* Add RunStackService API implementing `docker compose run` (#732)

* Add RunStackService API implementing `docker compose run`

* Add working Procedure configuration

* Remove `km execute run` alias. Remove redundant ``#[serde(default)]` on `Option`.

* Refactor command from `String` to `Vec<String>`

* Implement proper shell escaping

* bump deps

* Update configuration.md - fix typo: "affect" -> "effect" (#747)

* clean up SendAlert doc

* deploy 1.19.1-dev-6

* env file args won't double pass env file

* deploy 1.19.1-dev-7

* Add Enter Key Support for Dialog Confirmations (#750)

* start 1.19.1

* deploy 1.19.1-dev-1

* Implement usePromptHotkeys for enhanced dialog interactions and UX

* Refactor usePromptHotkeys to enhance confirm button detection and improve UX

* Remove forceConfirmDialog prop from ActionWithDialog and related logic for cleaner implementation

* Add dialog descriptions to ConfirmUpdate and ActionWithDialog for better clarity and resolve warnings

* fix

* Restore forceConfirmDialog prop to ActionWithDialog for enhanced confirmation handling

* cleanup

* Remove conditional className logic from ConfirmButton

---------

Co-authored-by: mbecker20 <max@mogh.tech>

* Support complex file depency action resolution

* get FE compile

* deploy 1.19.1-dev-8

* implement additional file dependency configuration

* deploy 1.19.1-dev-9

* UI default file dependency None

* default additional file requires is None

* deploy 1.19.1-dev-10

* rename additional_files => config_files for clarity

* deploy 1.19.1-dev-11

* fix skip serializing if None

* deploy 1.19.1-dev-12

* stack file dependency toml parsing aliases

* fmt

* Add: Server Version Mismatch Warnings & Alert System (#748)

* start 1.19.1

* deploy 1.19.1-dev-1

* feat: implement version mismatch warnings in server UI
- Replace orange warning colors with yellow for better visibility
- Add version mismatch detection that shows warnings instead of OK status
Implement responsive "VERSION MISMATCH" badge layout
- Update server dashboard to include warning counts
- Add backend version comparison logic for GetServersSummary

* feat: add warning count to server summary and update backup documentation link

* feat: add server version mismatch alert handling and update server summary invalidation logic

* fix: correct version mismatch alert config and disabled server display

- Use send_version_mismatch_alerts instead of send_unreachable_alerts
- Show 'Unknown' instead of 'Disabled' for disabled server versions
- Remove commented VersionAlert and Alerts UI components
- Update version to 1.19.0

* cleanup

* Update TypeScript types after merge

* cleanup

* cleanup

* cleanup

* Add "ServerVersionMismatch" to alert types

* Adjust color classes for warning states and revert server update invalidation logic

---------

Co-authored-by: mbecker20 <max@mogh.tech>

* backend for build multi registry push support

* deploy 1.19.1-dev-13

* build multi registry configuration

* deploy 1.19.1-dev-14

* fix invalid tokens JSON

* DeployStackIfChanged restarts also update stack.info.deployed_contents

* update deployed services comments

* deploy 1.19.1-dev-15

* Enhance server monitoring with load average data and new server monitoring table (#761)

* add monitoring page

* initial table

* moving monitoring table to servers

* add cpu load average

* typeshare doesnt allow tuples

* fix GetHistoricalServerStats

* add loadAvg to the server monitoring table

* improve styling

* add load average chart

* multiple colors for average loads chart

* make load average chart line and non-stacked

* cleanup

* use server thresholds

* cleanup

* Change "Dependents:" to "Services:" in config file service dependency
selector

* deploy 1.19.1-dev-16

* 1.19.1

---------

Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: Brian Bradley <brian.bradley.p@gmail.com>
Co-authored-by: Ravi Wolter-Krishan <rkn@gedikas.net>
Co-authored-by: jack <45038833+jackra1n@users.noreply.github.com>
2025-08-24 12:51:04 -07:00
Marcel Pfennig
75ffbd559b Fix: Correct environment variable name for container stats polling rate (#752)
* docs(config): Update environment variable name and default value for container stats polling rate

* fix(config): Update default value for container stats polling rate to 30 seconds
2025-08-21 15:01:10 -07:00
mbecker20
cae80b43e5 fix ferret v2 migration link 2025-08-18 16:38:03 -07:00
mbecker20
d924a8ace4 fix ferret v2 upgrade link 2025-08-18 11:36:25 -07:00
Karl Woditsch
dcfad5dc4e docs(docker-compose): Fix obsolete repo-cache volume declaration (#741) 2025-08-18 11:29:38 -07:00
mbecker20
134d1697e9 include backups path in env / yaml 2025-08-18 10:46:20 -07:00
mbecker20
3094d0036a edit cli docs 2025-08-17 21:00:04 -07:00
mbecker20
ee5fd55cdb first server commented out in default config 2025-08-17 18:39:27 -07:00
mbecker20
0ca126ff23 fix broken docs links before publish 2025-08-17 18:21:01 -07:00
Maxwell Becker
2fa9d9ecce 1.19.0 (#722)
* start 1.18.5

* prevent empty additional permission check (ie for new resources)

* dev-2

* bump rust to 1.88

* tweaks

* repo based stack commit happens from core repo cache rather than on server to simplify

* clippy auto fix

* clippy lints periphery

* clippy fix komodo_client

* dev-3

* emphasize ferret version pinning

* bump svi with PR fix

* dev-4

* webhook disabled early return

* Fix missing alert types for whitelist

* add "ScheduleRun"

* fix status cache not cleaning on resource delete

* dev-5

* forgot to pipe through poll in previous refactor

* refetch given in ms

* fix configure build extra args

* reorder resource sync config

* Implement ability to run actions at startup (#664)

* Implement ability to run actions at startup

* run post-startup actions after server is listening

* startup use action query

* fmt

* Fix Google Login enabled message (#668)

- it was showing "Github Login" instead of "Google Login"

* Allow CIDR ranges in Allowed IPs (#666)

* Allow CIDR ranges in Allowed IPs

* Catch mixed IPv4/IPv6 mappings that are probably intended to match

* forgiving vec

* dev-6

* forgiving vec log. allowed ips docs

* server stats UI: move current disk breakdown above charts

* searchable container stats, toggle collaple container / disk sections

* Add Clear repo cache method

* fix execute usage docs

* Komodo managed env-file should take precedence in all cases (ie come last in env file list)

* tag include unused flag for future use

* combine users page search

* util backup / restore

* refactor backup/restore duplication

* cleanup restore

* core image include util binary

* dev-7

* back to LinesCodec

* dev-8

* clean up

* clean up logs

* rename to komodo-util

* dev-9

* enable_fance_toml

* dev-10 enable fancy toml

* add user agent to oidc requests (#701)

Co-authored-by: eleith <online-github@eleith.com>

* fmt

* use database library

* clippy lint

* consolidate and standardize cli

* dev-11

* dev-12 implement backup using cli

* dev-13 logs

* command variant fields need to be #[arg]

* tweak cli

* gen client

* fix terminal reconnect issue

* rename cli to `km`

* tweaks for the cli logs

* wait for enter on --yes empty println

* fix --yes

* dev-15

* bump deps

* update croner to latest, use static parser

* dev-16

* cli execute polls updates until complete before logging

* remove repo cache mount

* cli nice

* /backup -> /backups

* dev-17 config loading preserves CONFIG_PATHS precedence

* update dockerfile default docker cli config keywords

* dev-18

* support .kmignore

* add ignores log

* Implement automatic backup pruning, default 14 backups before prune

* db copy / restore uses idempotent upsert

* cli update variable - "km set var VAR value"

* improve cli initial logs

* time the executions

* implement update for most resources

* dev 20

* add update page

* dev 21 support cli update link

* dev-22 test the deploy

* dev-23 use indexmap

* install-cli.py

* Frontend mobile fixes (#714)

* Allow ResourcePageHeader items to wrap

* Allow CardHeader items to wrap

* Increase z-index of sticky TableHeader, fixes #690

* Remove fixed widths from ActionButton, let them flex more to fit more layouts

* Make Section scroll overflow

* Remove grid class from Tabs, seems to prevent them from overflowing at small sizes

* deploy 1.18.5-dev-24

* auto version increment and deploy

* cli: profiles support aliases and merge on top of Default (root) config

* fix page set titles

* rust 1.89 and improve config logs

* skip serializing for proper merge

* fix clippy lints re 1.89

* remove layouts overflow-x-scroll

* deploy 1.18.5-dev-25

* 1.89 docker images not ready yet

* km cfg -a (print all profiles)

* include commit variables

* skip serializing profiles when empty

* skip serialize default db / log configs

* km cfg --debug print mode

* correct defaults for CLI and only can pass restore folder from cli arg

* some more skip serialization

* db restore / copy index optional

* add runfile command aliases

* remove second schedule updating loop, can causes some schedules to be missed

* deploy 1.18.5-dev-26

* add log when target db indexing disabled

* cli: user password reset, update user super admin

* Add manual network interface configuration for multi-NIC Docker environments (#719)

* Add iproute2 to debian-debs

* feat: Add manual network interface configuration for multi-NIC support

Complete implementation of manual interface configuration:
- Add internet_interface config option
- Implement manual gateway routing
- Add NET_ADMIN capability requirement
- Clean up codebase changes

* fix: Update internet interface handling for multi-NIC support

* refactor: Enhance error messages and logging in networking module

* refactor: Simplify interface argument handling and improve logging in network configuration and cleanup

* refactor(network): simplify startup integration and improve error handling

- Move config access and error handling into network::configure_internet_gateway()
- Simplify startup.rs to single function call without parameters
- Remove redundant check_network_privileges() function
- Improve error handling by checking actual command output instead of pre-validation
- Better separation of concerns between startup and network modules

Addresses feedback from PR discussion:
https://github.com/moghtech/komodo/pull/719#discussion_r2261542921

* fix(config): update default internet interface setting
Addresses feedback from PR discussion:
https://github.com/moghtech/komodo/pull/719#discussion_r2261552279

* fix(config): remove custom default for internet interface in CoreConfig

* move mod.rs -> network.rs
Addresses feedback from PR discussion:
https://github.com/moghtech/komodo/pull/719#discussion_r2261558332

* add internet interface example

* docs(build-images): document multi-platform builds with Docker Buildx (#721)

* docs(build-images): add multi-platform buildx guide to builders.md

* docs(build-images): add multi-platform buildx guide and clarify platform selection in Komodo UI Extra Args field

* move to 1.19.0

* core support reading from multiple config files

* config support yaml

* deploy 1.19.0-dev-1

* deploy 1.19.0-dev-2

* add default komodo cli config

* better config merge with base

* no need to panic if empty config paths

* improve km --help

* prog on cli docs

* tweak cli docs

* tweak doc

* split the runfile commands

* update docsite deps

* km ps initial

* km ls

* list resource apis

* km con inspect

* deploy 1.19.0-dev-3

* fix: need serde default

* dev-4 fix container parsing issue

* tweak

* use include-based file finding for much faster discovery

* just move to standard config dir .config/komodo/komodo.cli.*

* update fe w/ new contianer info minimal serialization

* add links to table names

* deploy 1.19.0-dev-5

* links in tables

* backend for Action arguments

* deploy 1.19.0-dev-6

* deploy 1.19.0-dev-7

* deploy 1.19.0-dev-8

* no space at front of KeyValue default args

* webhook branch / body optional

* The incoming arguments

* deploy 1.19.0-dev-9

* con -> cn

* add config -> cf alias

* .kmignore

* .peripheryinclude

* outdated

* optional links, configurable table format

* table_format -> table_borders

* get types

* include docsite in yarn install

* update runnables command in docs

* tweak

* improve km ls only show important stuff

* Add BackupCoreDatabase

* deploy 1.19.0-dev-10

* backup command needs "--yes"

* deploy 1.19.0-dev-11

* update rustc 1.89.0

* cli tweak

* try chef

* Fix chef (after dependencies)

* try other compile command

* fix

* fix comment

* cleanup stats page

* ensure database backup procedure

* UI allow configure Backup Core Database in Procedures

* procedure description

* deploy 1.19.0-dev-12

* deploy 1.19.0-dev-13

* GlobalAutoUpdate

* deploy 1.19.0-dev-14

* default tags and global auto update procedure

* deploy 1.19.0-dev-15

* trim the default procedure descriptions

* deploy 1.19.0-dev-16

* in "system" theme, also poll for updates to the theme based on time.

* Add next run to Action / Procedure column

* km ls support filter by templates

* fix procedure toml serialization when params = {}

* deploy 1.19.0-dev-17

* KOMODO_INIT_ADMIN_USERNAME

* KOMODO_FIRST_SERVER_NAME

* add server.config.external_address for use with links

* deploy 1.19.0-dev-18

* improve auto prune

* fix system theme auto update

* deploy 1.19.0-dev-19

* rename auth/CreateLocalUser -> SignUpLocalUser. Add write/CreateLocalUser for in-ui initialization.

* deploy 1.19.0-dev-20

* UI can handle multiple active logins

* deploy 1.19.0-dev-21

* fix

* add logout function

* fix oauth redirect

* fix multi user exchange token function

* default external address

* just Add

* style account switcher

* backup and restore docs

* rework docsite file / sidebar structure, start auto update docs

* auto update docs

* tweak

* fix doc links

* only pull / update running stacks / deployments images

* deploy 1.19.0-dev-22

* deploy 1.19.0-dev-23

* fix #737

* community docs

* add BackupCoreDatabase link to docs

* update ferret v2 update guide using komodo-cli

* fix data table headers overlapping topbar

* don't alert when deploying

* CommitSync returns Update

* deploy 1.19.0-dev-24

* trim the decoded branch

* action uses file contents deserializer

* deploy 1.19.0-dev-25

* remove Toml from action args format

* clarify External Address purpose

* Fix podman compatibility in `get_container_stats` (#739)

* Add podman compability for querying stats

Podman and docker stats differ in results in significant ways but this filter change they will output the same stats

* syntax fix

* feat(dashboard): display CPU, memory, and disk usage on server cards (#729)

* feat: mini-stats-card: Expose Server CPU , Memory, Disk Usage to Dashboard View

* comment: resolved

* Feat: fix overflow card , DRY stats-mini, add unreachable mini stats

* lint: fix

* deploy 1.19.0-dev-26

* 1.19.0

* linux, macos container install

* cli main config

---------

Co-authored-by: Brian Bradley <brian.bradley.p@gmail.com>
Co-authored-by: Daniel <daniel.barabasa@gmail.com>
Co-authored-by: eleith <eleith@users.noreply.github.com>
Co-authored-by: eleith <online-github@eleith.com>
Co-authored-by: Sam Edwards <sam@samedwards.ca>
Co-authored-by: Marcel Pfennig <82059270+MP-Tool@users.noreply.github.com>
Co-authored-by: itsmesid <693151+arevindh@users.noreply.github.com>
Co-authored-by: mbecker20 <max@mogh.tech>
Co-authored-by: Rhyn <Rhyn@users.noreply.github.com>
Co-authored-by: Anh Nguyen <tuananh131001@gmail.com>
2025-08-17 17:25:45 -07:00
Maxwell Becker
118ae9b92c 1.18.4 (#604)
* update easy deps

* update otel deps

* implement template in types + update resource meta

* ts types

* dev-2

* dev-3 default template query is include

* Toggle resource is template in resource header

* dev-4 support CopyServer

* gen ts

* style template selector in New Resource menu

* fix new menu show 0

* add template market in omni search bar

* fix some dynamic import behavior

* template badge on dashboard

* dev-5

* standardize interpolation methods with nice api

* core use new interpolation methods

* refactor git usage

* dev-6 refactor interpolation / git methods

* fix pull stack passed replacers

*  new types

* remove redundant interpolation for build secret args

* clean up periphery docker client

* dev-7 include ports in container summary, see if they actually come through

* show container ports in container table

* refresh processes without tasks (more efficient)

* dev-8 keep container stats cache, include with ContainerListItem

* gen types

* display more container ports

* dev-9 fix repo clone when repo doesn't exist initially

* Add ports display to more spots

* fix function name

* add Periphery full container stats api, may be used later

* server container stats list

* dev-10

* 1.18.4 release

* Use reset instead of invalidate to fix GetUser spam on token expiry (#618)

---------

Co-authored-by: Jacky Fong <hello@huzky.dev>
2025-06-24 16:32:39 -07:00
Luke
2205a81e79 Update webhooks.md (#611) 2025-06-20 11:56:05 -07:00
mbecker20
e2280f38df fix: allow Build / Repo add Attach permission 2025-06-16 00:40:24 -07:00
Maxwell Becker
545196d7eb 1.18.3 (#603)
* start 1.18.3 branch

* git::pull will fetch before checkout

* dev-2

* 1.18.3 quick release
2025-06-15 23:45:50 -07:00
Maxwell Becker
23f8ecc1d9 1.18.2 (#591)
* feat: add maintenance window management to suppress alerts during planned activities (#550)

* feat: add scheduled maintenance windows to server configuration

- Add maintenance window configuration to server entities
- Implement maintenance window UI components with data table layout
- Add maintenance tab to server interface
- Suppress alerts during maintenance windows

* chore: enhance maintenance windows with types and permission improvements

- Add chrono dependency to Rust client core for time handling
- Add comprehensive TypeScript types for maintenance windows (MaintenanceWindow, MaintenanceScheduleType, MaintenanceTime, DayOfWeek)
- Improve maintenance config component to use usePermissions hook for better permission handling
- Update package dependencies

* feat: restore alert buffer system to prevent noise

* fix yarn fe

* fix the merge with new alerting changes

* move alert buffer handle out of loop

* nit

* fix server version changes

* unneeded buffer clear

---------

Co-authored-by: mbecker20 <becker.maxh@gmail.com>

* set version 1.18.2

* failed OIDC provider init doesn't cause panic, just  error log

* OIDC: use userinfo endpoint to get preffered username for user.

* add profile to scopes and account for username already taken

* search through server docker lists

* move maintenance stuff

* refactor maintenance schedules to have more toml compatible structure

* daily schedule type use struct

* add timezone to core info response

* frontend can build with new maintenance types

* Action monaco expose KomodoClient to init another client

* flatten out the nested enum

* update maintenance schedule types

* dev-3

* implement maintenance windows on alerters

* dev-4

* add IanaTimezone enum

* typeshare timezone enum

* maintenance modes almost done on servers AND alerters

* maintenance schedules working

* remove mention of migrator

* Procedure / Action schedule timezone selector

* improve timezone selector to display configure core TZ

* dev-5

* refetch core version

* add version to server list item info

* add periphery version in server table

* dev-6

* capitalize Unknown server status in cache

* handle unknown version case

* set server table sizes

* default resource_poll_interval 1-hr

* ensure parent folder exists before cloning

* document Build Attach permission

* git actions return absolute path

* stack linked repos

* resource toml replace linked_repo id with name

* validate incoming linked repo

* add linked repo to stack list item info

* stack list item info resolved linked repo information

* configure linked repo stack

* to repo links

* dev-7

* sync: replace linked repo with name for execute compare

* obscure provider tokens in table view

* clean up stack write w/ refactor

* Resource Sync / Build start support Repo attach

* add stack clone path config

* Builds + syncs can link to repos

* dev-9

* update ts

* fix linked repo not included in resource sync list item info

* add linked repo UI for builds / syncs

* fix commit linked repo sync

* include linked repo syncs

* correct Sync / Build config mode

* dev-12 fix resource sync inclusion w/ linked_repo

* remove unneed sync commit todo!()

* fix other config.repo.is_empty issues

* replace ids in all to toml exports

* Ensure git pull before commit for linear history, add to update logs

* fix fe for linked repo cases

* consolidate linked repo config component

* fix resource sync commit behavior

* dev 17

* Build uses Pull or Clone api to setup build source

* capitalize Clone Repo stage

* mount PullOrCloneRepo

* dev-19

* Expand supported container names and also avoid unnecessary name formatting

* dev-20

* add periphery /terminal/execute/container api

* periphery client execute_container_exec method

* implement execute container, deployment, stack exec

* gen types

* execute container exec method

* clean up client / fix fe

* enumerate exec ts methods for each resource type

* fix and gen ts client

* fix FE use connect_exec

* add url log when terminal ws fail to connect

* ts client server allow terminal.js

* FE preload terminal.js / .d.ts

* dev-23 fix stack terminal fail to connect when not explicitly setting container name

* update docs on attach perms

* 1.18.2

---------

Co-authored-by: Samuel Cardoso <R3D2@users.noreply.github.com>
2025-06-15 16:42:36 -07:00
Maxwell Becker
4d401d7f20 1.18.1 (#566)
* 1.18.1

* improve stack header / all resource links

* disable build config selector

* clean up deployment header

* update build header

* builder header

* update repo header

* start adding repo links from api

* implement list item repo link

* clean up fe

* gen client

* repo links across the board

* include state tracking buffer, so alerts are only triggered by consecutive out of bounds conditions

* add runnables-cli link in runfile

* improve frontend first load time through some code splitting

* add services count to stack header

* fix repo on pull

* Add dedicated Deploying state to Deployments and Stacks

* move predeploy script before compose config (#584)

* Periphery / core version mismatch check / red text

* move builders / alerts out of sidebar, into settings

* remove force push

* list schedules api

* dev-1

* actually dev-3

* fix action

* filter none procedures

* fix schedule api

* dev-5

* basic schedules page

* prog on schedule page

* simplify schedule

* use name to sort target

* add resource tags to schedule

* Schedule page working

* dev-6

* remove schedule table type column

* reorder schedule table

* force confirm  dialogs for delete, even if disabled in config

* 1.18.1

---------

Co-authored-by: undaunt <31376520+undaunt@users.noreply.github.com>
2025-06-06 23:08:51 -07:00
mbecker20
4165e25332 further clarify ferretdb setup for existing users 2025-06-01 13:50:03 -04:00
Maxwell Becker
4cc0817b0f Update copy-database.md 2025-05-30 15:08:19 -07:00
mbecker20
51cf1e2b05 clarify mongo / ferret in docs 2025-05-30 17:14:42 -04:00
mbecker20
5309c70929 update runfile 2025-05-30 17:01:15 -04:00
mbecker20
1278c62859 update specific permission in docs 2025-05-30 16:58:28 -04:00
mbecker20
6d6acdbc0b fix permissions list 2025-05-30 16:49:27 -04:00
mbecker20
d22000331e remove logging driver from compose example 2025-05-30 16:14:21 -04:00
Maxwell Becker
31034e5b34 1.18.0 (#555)
* ferretdb v2 now that they support arm64

* remove ignored for sqlite

* tweak

* mongo copier

* 1.17.6

* primary name is ferretdb option

* give doc counts

* fmt

* print document count

* komodo util versioned seperately

* add copy startup sleep

* FerretDB v2 upgrade guide

* tweak docs

* tweak

* tweak

* add link to upgrade guide for ferretdb v1 users

* fix copy batch size

* multi arch util setup

* util use workspace version

* clarify behavior re root_directory

* finished copying database log

* update to rust:1.87.0

* fix: reset rename editor on navigate

* loosen naming restrictions for most resource types

* added support for ntfy email forwarding (#493)

* fix alerter email option docs

* remove logging directive in example compose - can be done at user discretion

* more granular permissions

* fix initial fe type errors

* fix the new perm typing

* add dedicated ws routes to connect to deployment / stack terminal, using the permissioning on those entities

* frontend should convey / respect the perms

* use IndexSet for SpecificPermission

* finish IndexSet

* match regex or wildcard resource  name pattern

* gen ts client

* implement new terminal components which use the container / deployment / stack specific permissioned endpoints

* user group backend "everyone" support

* bump to 1.18.0 for significant permissioning changes

* ts 1.18.0

* permissions FE in prog

* FE permissions assignment working

* user group all map uses ordered IndexMap for consistency

* improve user group toml and fix execute bug

* URL encode names in webhook urls

* UI support configure 'everyone' User Group

* sync handle toggling user group everyone

* user group table show everyone enabled

* sync will update user group "everyone"

* Inspect Deployment / Stack containers directly

* fix InspectStackContainer container name

* Deployment / stack service inspect

* Stack / Deployment inherit Logs, Inspect and Terminal from their attached server for user

* fix compose down not capitalized

* don't use tabs

* more descriptive permission table titles

* different localstorage for permissions show all

* network / image / volume inspect don't require inspect perms

* fix container inspect

* fix list container undefined error

* prcesses list gated UI

* remove localstorage on permission table expansion

* fix ug sync handling of all zero permissions

* pretty log startup config

* implement actually pretty logging initial config

* fix user permissions when api returns string

* fix container info table

* util based on bullseye-slim

* permission toml specific skip_serializing_if = "IndexSet::is_empty"

* container tab permissions reversed

* reorder pretty logging stuff to be together

* update docs with permissioning info

* tweak docs

* update roadmap

---------

Co-authored-by: FelixBreitweiser <felix.breitweiser@uni-siegen.de>
2025-05-30 12:52:58 -07:00
Avalancs
a43e1f3f52 Add Keycloak instructions to OIDC setup (#517) 2025-05-18 15:49:11 -07:00
jeroenvds
7a3b2b542d Removing ServerTemplate in docs (#492)
Removing ServerTemplate from Resources documentation, as it was removed in Release v1.17.5
2025-05-08 02:43:45 -04:00
Cesar Villegas
8d516d6d5f fix: api_key -> key in Typescript client initialization (#485) 2025-05-06 11:22:02 -07:00
Maxwell Becker
3e0d1befbd 1.17.5 (#472)
* API support new calling syntax

* finish /{variant} api to improve network logs in browser console

* update roadmap

* configure the shell used to start the pty

* start on ExecuteTerminal api

* Rename resources less hidden - click on name in header

* update deps

* execute terminal

* BatchPullStack

* add Types import to Actions, and don't stringify the error

* add --reload for cached deps

* type execute terminal response as AsyncIterable

* execute terminal client api

* KOMODO_EXIT_CODE

* Early exit without code

* action configurable deno dep reload

* remove ServerTemplate resource

* kept disabled

* rework exec terminal command wrapper

* debug: print lines in start sentinel loop

* edit debug / remove ref

* echo

* line compare

* log lengths

* use printf again

* check char compare

* leading \n

* works with leading \n

* extra \n after START_OF_OUTPUT

* add variables / secrets finders to ui defined stacks / builds

* isolate post-db startup procedures

* clean up server templates

* disable websocket reconnect from core config

* change periphery ssl enabled to default to true

* git provider selector config pass through disable to http/s button

* disable terminals while allowing container exec

* disable_container_exec in default config

* update ws reconnect implementation

* Don't show delete tag non admin and non owner

* 1.17.5 complete
2025-05-04 14:45:31 -07:00
mbecker20
5dc609b206 add examples for perihery config fields 2025-04-28 18:14:40 -04:00
mbecker20
f1127007c3 update intro with shell features 2025-04-27 19:21:55 -04:00
Maxwell Becker
765e5a0df1 1.17.4 (#446)
* add terminal (ssh) apis

* add core terminal exec method

* terminal typescript client method

* terminals WIP

* backend for pty

* add ts responses

* about wire everything

* add new blog

* credit Skyfay

* working

* regen lock

* 1.17.4-dev-1

* pty history

* replace the test terminal impl with websocket (pty)

* create api and improve frontend

* fix fe

* terminals

* disable terminal api on periphery

* implement write level terminal perms

* remove unneeded

* fix clippy

* delete unneeded

* fix waste cpu cycles

* set TERM and COLORTERM for shell environment

* fix xterm scrolling behavior

* starship promp in periphery container terminal

* kill all terminals on periphery shutdown signal

* improve starship config and enable ssl in compose

* use same scrollTop setter

* fix periphery container distribution link

* support custom command / args to init terminal

* allow fully configurable init command

* docker exec into container

* add permissioning for container exec

* add starship to core container

* add delete all terminals

* dev-2

* finished gen client

* core need curl

* hide Terminal trigger if disabled

* 1.17.4
2025-04-27 15:53:23 -07:00
mbecker20
76f2f61be5 1.17.3 fix Build pre_build functionality. 2025-04-24 22:03:46 -04:00
Maxwell Becker
b43e2918da 1.17.2 (#409)
* start on cron schedules

* rust 1.86.0

* config periphery directories easier with PERIPHERY_ROOT_DIRECTORY

* schedule backend

* fix config switch toggling through disabled

* procedure schedule working

* implement schedules for actions

* update schedule immediately after last run

* improve config update logs using toml diffs backend

* improve the config update logs with TOML diff view

* add schedule alerting

* version 1.17.2

* Set TZ in core env

* dev-1

* better term signal labels

* sync configurable pending alert send

* fix monaco editor height on larger screen

* poll update until complete on client

update lib

* add logger.pretty option for both core and periphery

* fix pretty

* configure schedule alert

* configure failure alert

* dev-3

* 1.17.2

* fmt

* added pushover alerter (#421)

* fix up pushover

* fix some clippy

---------

Co-authored-by: Alex Shore <alex@shore.me.uk>
2025-04-18 23:14:10 -07:00
Etienne
f45205011e Fix compose.env (#415)
Flip "stacks" and "repos" paths
2025-04-15 12:11:48 -07:00
Maxwell Becker
5b211fb8f0 1.17.1 (#383)
* interpolate into slack / discord url

* fix js client docs

* js client should be type: module

* click table tags to toggle tag filter

* git token helper early return when empty provider

* reorder Stack fields

* action support interpolation doc

* Fix for the {account} login fails when the account name contains '$' (#385)

* GetDeploymentsSummary (#386)

* added GetDockerContainersSummary endpoint in rust api

* typescript stuff

* more autogenned typescript stuff

* fixed comments to be in line with actual behaviour

* fixed ReadResponse for GetDockerContainersSummary

* I64 -> u32 for response types

* more accurate error context

* backend for build files on host / ui defined

* core api supports non repo based build

* Ntfy as Alerter (#404)

* add ntfy alerter

* add ntfy alerter

* add ntfy alerter

---------

Co-authored-by: GFXSpeed <github@schafhaupt.com>

* Improve docs around running periphery in a container (#402)

* Add ports that need to be exposed if periphery is remote from core

* Spelling: overide -> override

* Add info about using a custom config file when running periphery in docker

* clean up ntfy alerter

* clean up ResourceSyncConfig

* update build cache after create / update

* refresh stack cache log

* Build UI Defined / file on host frontend

* update clap + rustls

* don't cleanup build files

* clean up dockerfile full path

* update BuildListItemInfo + UI table

* add Other Resources page

* add 5 second ws reconnection timeout

* Make listener address configurable & Add support for IPv6 (#405)

* Make listener address configurable

* Make listener address configurable for periphery

* rename listener_address -> bind_ip

---------

Co-authored-by: Jacky Fong <hello@huzky.dev>
Co-authored-by: Alex Shore <kntrllr@gmail.com>
Co-authored-by: Niklas <108990063+GFXSpeed@users.noreply.github.com>
Co-authored-by: GFXSpeed <github@schafhaupt.com>
Co-authored-by: theRAAPster <theraapster@gmail.com>
Co-authored-by: Daniel Vos <45820840+vosdev@users.noreply.github.com>
2025-04-14 14:48:12 -07:00
Justin Goette
3f04614303 docs: Typo (#392)
FerretDB vs. FerritDB
2025-04-02 00:05:29 -07:00
mbecker20
4c3210f70a update dashboard screenshots with colored tags 2025-03-23 23:17:33 -07:00
mbecker20
2c37ac26a5 ts client 1.17.0 2025-03-23 17:42:47 -07:00
Maxwell Becker
db1cf786ac 1.17.0 (#248)
* resolver v3

add new ec2 instance types

clean up testing config

document the libraries a bit

clean up main

update sysinfo and otel

update client resolver 3.0

resolver v3 prog

clean up gitignore

implement periphery resolver v3

clean up

core read api v3

more prog

execute api

missing apis

compiling

1.16.13

work on more granular traits

prog on crud

* fmt

* format

* resource2 not really a benefit

* axum to 0.8

* bump aws deps

* just make it 1.17.0

* clean up cors

* the komodo env file should be highest priority over additional files

* add entities / message for test alerter

* test alert implementation

* rust 1.84.0

* axum update :param to {param} syntax

* fix last axum updates

* Add test alerter button

* higher quality / colored icons

* komodo-logo

* simplify network stats

* rename Test Alerter button

* escape incoming sync backslashes (BREAKING)

* clean up rust client websocket subscription

* finish oidc comment

* show update available stack table

* update available deployment table

* feature: use the repo path instead of name in GetLatestCommit (#282)

* Update repo path handling in commit fetching

- Changed `name` to `path` for repository identification.
- Updated cache update function to use the new path field.
- Improved error message for non-directory repo paths.

* feat: use optional name and path in GetLatestCommit

* review: don't use optional for name

* review: use helper

* review: remove redundant to_string()

* 1.17.0-dev

* feature: add post_deploy command (#288)

* feature: add post_deploy command

* review: do not run post_deploy if deploy failed

* feature: interpolate secrets in custom alerter (#289)

* feature: interpolate secrets in custom alerter

* fix rust warning

* review: sanitize errors

* review: sanitize error message

* Remove .git from remote_url (#299)

Remove .git from remote_url

Co-authored-by: Deon Marshall <dmarshall@ccp.com.au>

* mbecker20 -> moghtech

* remove example from cargo toml workspace

* dev-1

* fix login screen logo

* more legible favicon

* fix new compose images

* docs new organization

* typescript subscribe_to_update_websocket

* add donate button docsite

* add config save button in desktop sidebar navigator

* add save button to config bottom

* feature: allow docker image text to overflow in table (#301)

* feature: allow docker image text to overflow in table

* review: use break-words

* wip: revert line break in css file

* feature: update devcontainer node release

* improve First Login docs

* FIx PullStack re #302 and record docker compose config on stack deploy

* requery alerts more often

* improve update indicator style and also put on home screen

* Add all services stack log

* 1.17.0-dev-2

* fix api name chnage

* choose which stack services to include in logs

* feature: improve tables quick actions on mobile (#312)

* feature: improve tables quick actions on mobile

* review: fix gap4

* review: use flex-wrap

* improve pull to git init on existing folder without .git

* Fix unclear ComposePull log re #244

* use komodo_client.subscribe_to_update_websocket, and click indicator to reconnect

* dev-3

* ServerTemplate description

* improve WriteComposeContentsToHost instrument fields

* give server stat charts labels

* filters wrap

* show provider usernames from config file

* Stack: Fix git repo new compose file initialization

* init sync file new repo

* set branch on git init folder

* ResourceSync: pending view toggle between "Execute" vs "Commit" sync direction

* Improve resource sync Execute / Pending view selector

* standardize running commands with interpolation / output sanitizations

* fix all clippy lints

* fix rand

* lock certain users username / password, prevent demo creds from being changed.

* revert to login screen whenever the call to check login fails

* ResourceSync state resolution refinement

* make sure parent directories exist whenever writing files

* don't prune images if server not enabled

* update most deps

* update openidconnect dependency, and use reqwest rustls-tls-native-roots

* dev-4

* resource sync only add escaping on toml between the """

* Stacks executions take list of services -- Auto update only redeploys services with update

* auto update all service deploy option

* dev-5 fix the stack service executions

* clean up service_args

* rust 1.85

* store sync edits on localstorage

* stack edits on localstorage and show last deployed config

* add yarn install to runfile

* Fix actions when core on https

* add update_available query parameter to filter for only stacks /deployments with available update

* rust 2024 and fmt

* rename test.compose.yaml to dev.compose.yaml, and update runfile

* update .devcontainer / dev docs for updated runfile

* use png in topbar logo, svg quality sometimes bad

* OIDC: Support PKCE auth (secret optional)

* update docs on OIDC and client secret

* cycle the oidc client on interval to ensure up to date JWKs

* add KOMODO_LOCK_LOGIN_CREDENTIALS_FOR in config doc

* update deps

* resource sync toggle resource / variable / user group inclusion independantly

* use jsonwebtoken

* improve variable value table overflow

* colored tags

* fix sync summary count ok

* default new tag colors to grey

* soften tag opacity a bit

* Update config.tsx (#358)

* isolate stacks / deployments with pending updates

* update some deps

* use Tooltip component instead of HoverCard for mobile compatibility

* batch Build builds

* link to typescript client in the intro

* add link to main docs from client docs

* doc tweaks

* use moghtech/komodo-core and moghtech/komodo-periphery as images

* remove unnecessary explicit network

* periphery.compose.yaml

* clean up periphery compose

* add link to config

* update periphery container compose config

* rust 1.85.1

* update sync docs

* 1.17.0

---------

Co-authored-by: unsync <1211591+unsync@users.noreply.github.com>
Co-authored-by: Deon Marshall <dmarshall@ccp.com.au>
Co-authored-by: komodo <komodo@komo.do>
Co-authored-by: wlatic <jamesoh@gmail.com>
2025-03-23 16:47:06 -07:00
Maarten Kossen
9c841e5bdc Change amd64 to arm64 to prevent installing aarch64 binary on an x86_64 system. (#357) 2025-03-12 19:20:49 -07:00
mbecker20
e385c6e722 use ferretdb:1 2025-02-26 14:55:34 -08:00
Maxwell Becker
9ef25e7575 Create FUNDING.yml 2025-02-13 12:07:48 -08:00
boomam
f945a3014a Update index.mdx (#306)
Added small note on initial login steps.
2025-02-11 00:03:39 -08:00
mbecker20
fdad04d6cb fix KOMODO_DB_USERNAME compose files 2025-02-08 18:45:02 -08:00
mbecker20
c914f23aa8 update compose files re #180 2025-02-08 12:29:26 -08:00
Maarten Kossen
82b2e68cd3 Adding Resource Sync documentation. (#259) 2025-01-11 21:32:02 -08:00
rita7lopes
e274d6f7c8 Network Usage - Ingress Egress per interface and global usage (#229)
* Add network io stats

Add network usage graph and current status

Change network graphs to use network interface from drop down menu

Adjust the type to be able to get general network stats for the general
UI view

Working setup with a working builder

remove changes to these dockerfile

remove lock changes

* change network hashmap to Vector

* adjust all the network_usage_interface to be a vector rather than a hash map

* PR requested changes applied

* Change net_ingress_bytes and egress to network_ingress_bytes egress respectively

* final gen-client types

---------

Co-authored-by: mbecker20 <becker.maxh@gmail.com>
2024-12-21 08:15:21 -08:00
Maxwell Becker
ab8777460d Simplify periphery aio.Dockerfile 2024-12-14 09:13:01 -08:00
Maxwell Becker
7e030e702f Simplify aio.Dockerfile 2024-12-14 09:12:32 -08:00
Maxwell Becker
a869a74002 Fix test.compose.yaml (use aio.Dockerfiles) 2024-12-14 09:11:24 -08:00
mbecker20
1d31110f8c fix multi arch built var reference 2024-12-02 03:33:11 -05:00
mbecker20
bb63892e10 periphery -> periphery-x86_64 setup script 2024-12-02 03:31:55 -05:00
mbecker20
4e554eb2a7 add labels to binary / frontend images 2024-12-02 03:02:00 -05:00
Maxwell Becker
00968b6ea1 1.16.12 (#209)
* inc version

* Komodo interp in ui compose file

* fix auto update when image doesn't specify tag by defaulting to latest

* Pull image buttons don't need safety dialog

* WIP crosscompile

* rename

* entrypoint

* fix copy

* remove example/* from workspace

* add targets

* multiarch pkg config

* use specific COPY

* update deps

* multiarch build command

* pre compile deps

* cross compile

* enable-linger

* remove spammed log when server doesn't have docker

* add multiarch.Dockerfile

* fix casing

* fix tag

* try not let COPY fail

* try

* ARG TARGETPLATFORM

* use /app for consistency

* try

* delete cross-compile approach

* add multiarch core build

* multiarch Deno

* single arch multi arch

* typeshare cli note

* new typeshare

* remove note about aarch64 image

* test configs

* fix config file headers

* binaries dockerfile

* update cargo build

* docs

* simple

* just simple

* use -p

* add configurable binaries tag

* add multi-arch

* allow copy to fail

* fix binary paths

* frontend Dockerfiel

* use dedicated static frontend build

* auto retry getting instance state from aws

* retry 5 times

* cleanup

* simplify binary build

* try alpine and musl

* install alpine deps

* back to debian, try rustls

* move fully to rustls

* single arch builds using single binary image

* default IMAGE_TAG

* cleanup

* try caching deps

* single arch add frontend build

* rustls::crypto::ring::default_provider()

* back to simple

* comment dockerfile

* add select options prop, render checkboxes if present

* add allowSelectedIf to enable / disable rows where necessary

* rename allowSelectIf to isSelectable, allow false as global disable, disable checkboxes when not allowed

* rename isSelectable to disableRow (it works the oppsite way lol)

* selected resources hook, start deployment batch execute component

* add deployment group actions

* add deployment group actions

* add default (empty) group actions for other resources

* fix checkbox header styles

* explicitly check if disableRow is passed (this prop is cursed)

* don't disable row selection for deployments table

* don't need id for groupactions

* add group actions to resources page

* fix row checkbox (prop not cursed, i dumb)

* re-implement group action list using dropdown menu

* only make group actions clickable when at least one row selected

* add loading indicator

* gap betwen new resource and group actions

* refactor group actions

* remove "Batch" from action labels

* add group actions for relevant resources

* fix hardcode

* add selectOptions to relevant tables

* select by name not id

* expect selected to be names

* add note re selection state init for future reference

* multi select working nicely for all resources

* configure server health check timeout

* config message

* refresh processes remove dead processes

* simplify the build args

* default timeout seconds 3

---------

Co-authored-by: kv <karamvir.singh98@gmail.com>
2024-12-01 23:34:07 -08:00
mbecker20
a8050db5f6 1.16.11 bump version 2024-11-14 02:18:26 -05:00
Maxwell Becker
bf0a972ec2 1.16.11 (#187)
* fix discord stack auto updated link

* action only log completion correctly

* add containers to omni search

* periphery build use --push

* use --password-stdin to login

* docker login stdin
2024-11-13 23:17:35 -08:00
mbecker20
23c1a08c87 fix Action success log being triggered even when there is error. 2024-11-12 19:43:55 -05:00
mbecker20
2b6b8a21ec revert monaco scroll past last line 2024-11-08 03:47:35 -05:00
mbecker20
02974b9adb monaco enable scroll beyond last line 2024-11-08 03:44:54 -05:00
Maxwell Becker
64d13666a9 1.16.10 (#178)
* send alert on auto update

* scrolling / capturing monaco editors

* deployed services has correct image

* serde default services for backward compat

* improve auto update config
2024-11-07 23:59:52 -08:00
mbecker20
2b2f354a3c add ImageUpdateAvailable filter to alert page 2024-11-05 01:30:55 -05:00
Maxwell Becker
aea5441466 1.16.9 (#172)
* BatchDestroyDeployment

* periphery image pull api

* Add Pull apis

* Add PullStack / PullDeployment

* improve init deploy from container

* stacks + deployments update_available source

* Fix deploy / destroy stack service

* updates available indicator

* add poll for updates and auto update options

* use interval to handle waiting between resource refresh

* stack auto update deploy whole stack

* format

* clean up the docs

* update available alerts

* update alerting format

* fix most clippy
2024-11-04 20:28:31 -08:00
mbecker20
97ced3b2cb frontend allow Alerter configure StackStateChanged, include Stacks and Repos in whitelist 2024-11-02 21:00:38 -04:00
Maxwell Becker
1f79987c58 1.16.8 (#170)
* update configs

* bump to 1.16.8
2024-11-01 14:35:12 -07:00
Maxwell Becker
e859a919c5 1.16.8 (#169)
* use this to extract from path

* Fix references to __ALL__
2024-11-01 14:33:41 -07:00
mbecker20
2a1270dd74 webhook check will return better status codex 2024-11-01 15:57:36 -04:00
Maxwell Becker
f5a59b0333 1.16.7 (#167)
* 1.16.7

* increase builder max poll to allow User Data more time to setup periphery

* rework to KOMODO_OIDC_REDIRECT_HOST
2024-10-31 21:06:01 -07:00
mbecker20
cacea235f9 replace networks empty with network_mode, replace container: network mode 2024-10-30 02:58:27 -04:00
mbecker20
54ba31dca9 gen ts types 2024-10-30 02:18:57 -04:00
Maxwell Becker
17d7ecb419 1.16.6 (#163)
* remove instrument from validate_cancel_build

* use type safe AllResources map - Action not showing omnisearch

* Stack support replicated services

* server docker nested tables

* fix container networks which use network of another container

* bump version

* add 'address' to ServerListItemInfo

* secrets list on variables page wraps

* fix user data script

* update default template user data

* improve sidebar layout styling

* fix network names shown on containers

* improve stack service / container page

* deleted resource log records Toml backup for later reference

* align all the tables

* add Url Builder type
2024-10-29 23:17:10 -07:00
mbecker20
38f3448790 add Procedures and Actions page 2024-10-28 00:57:42 -04:00
Maxwell Becker
ec88a6fa5a 1.16.5 (#159)
* repo pull lock

* implement BatchRunAction

* other batch methods
2024-10-27 20:56:56 -07:00
mbecker20
3820cd0ca2 make Build Organization configurable with custom value 2024-10-27 14:42:06 -04:00
mbecker20
419aa87bbb update resource toml examples to latest standard 2024-10-26 16:22:12 -04:00
Maxwell Becker
7a9ad42203 1.16.4 (#151)
* rust client improvements and docs

* sync rust client

* version 1.16.4

* UI support YAML / TOML utils, typed Deno namespace

* add ResourcesToml to typeshare

* add YAML and TOML convenience

* make the types available globally

* preload container with @std/yaml and @std/toml, clean up genned files

* add deno setup to alpine dockerfile
2024-10-26 12:15:34 -07:00
mbecker20
3f1cfa9064 update docs. Add Variables / Secrets docs 2024-10-25 00:02:12 -04:00
Maxwell Becker
d05c81864e 1.16.3 (#150)
* refactor listener api implementation for Gitlab integration

* version 1.16.3

* builder delete id link cleanup

* refactor and add "__ALL__" branch to avoid branch filtering

* frontend config the webhook url

* action webhook config

* clean up webhook url copy

* add __ALL__ branch switch for Actions / Procedures
2024-10-24 16:03:00 -07:00
mbecker20
f1a09f34ab tweak dev docs and runfile 2024-10-22 17:04:49 -04:00
mbecker20
23c6e6306d fix usage of runnables-cli in dev docs 2024-10-22 16:36:25 -04:00
mbecker20
800da90561 tweaks to dev docs 2024-10-22 15:25:33 -04:00
mbecker20
b24bf6ed89 fix docsite broken links 2024-10-22 15:17:10 -04:00
Matt Foxx
d66a781a13 docs: Add development docs (#136)
* docs: Add development POC

* docs: Flesh out full build/run steps

* feat: Add mergeable compose file to expose port and internal periphery url

* feat: Add .devcontainer and VSCode Tasks for developing Komodo

* Make cargo cache persistent in devcontainer

* Add deno to devcontainer

* Update tasks to include TS client copy to frontend before run

* Recommend extensions for used dependencies in vscode workspace

All extensions recommended are included in the devcontainer. This makes it easier for users not using devcontainer to get lang support.

* Update local `run` sequence for development docs
2024-10-22 12:09:26 -07:00
Maxwell Becker
f9b2994d44 1.16.2 (#145)
* Env vars written using same quotes (single vs double) as the user passes

* fmt

* trim start matches '-'

* ts client version
2024-10-22 11:41:17 -07:00
mbecker20
c0d6d96b64 get username works for service users 2024-10-22 03:36:20 -04:00
mbecker20
34496b948a bump ts client to 1.16.1 2024-10-22 02:58:42 -04:00
mbecker20
90c6adf923 fix periphery installer force file recreation command 2024-10-22 02:55:39 -04:00
mbecker20
3b72dc65cc remove "Overviews" label from sidebar 2024-10-22 02:27:22 -04:00
mbecker20
05f38d02be bump version to 1.16.1 2024-10-22 02:21:16 -04:00
Maxwell Becker
ea5506c202 1.16.1 (#143)
* ensure sync state cache is refreshed on sync create / copy

* clean up resources post_create

* show sidebar if element length > 1

* update `run_komodo_command` command

* rename all resources

* refresh repo cache after clone / pull

* improve rename repo log
2024-10-21 23:19:40 -07:00
mbecker20
64b0a5c9d2 delete unrelated caddy compose 2024-10-21 00:30:54 -04:00
mbecker20
93cc6a3a6e Add running Action to dashboard "Active" 2024-10-21 00:21:17 -04:00
mbecker20
7ae69cf33b ignore top level return linting 2024-10-20 05:10:28 -04:00
mbecker20
404e00cc64 move action info inline 2024-10-20 04:43:29 -04:00
mbecker20
6fe5bc7420 properly host client lib for deno importing (types working) 2024-10-20 03:17:58 -04:00
mbecker20
82324b00ee typescript komodo_client v 1.16.0 2024-10-20 02:35:43 -04:00
Maxwell Becker
5daba3a557 1.16.0 (#140)
* consolidate deserializers

* key value list doc

* use string list deserializers for all entity Vec<String>

* add additional env files support

* plumbing for Action resource

* js client readme indentation

* regen lock

* add action UI

* action backend

* start on action frontend

* update lock

* get up to speed

* get action started

* clean up default action file

* seems to work

* toml export include action

* action works

* action works part 2

* bump rust version to 1.82.0

* copy deno bin from bin image

* action use local dir

* update not having changes doesn't return error

* format with prettier

* support yaml formatting with prettier

* variable no change is Ok
2024-10-19 23:27:28 -07:00
mbecker20
020cdc06fd remove migrator link in readme 2024-10-18 21:23:02 -04:00
Maxwell Becker
cb270f4dff 1.15.12 (#139)
* add containers link to mobile dropdown

* fix update / alert not showing permission issue

* prevent disk alert back and forth

* improve user group pending toml
2024-10-18 17:14:22 -07:00
Matt Foxx
21666cf9b3 feat: Add docs link to topbar (#134) 2024-10-18 16:10:01 -07:00
mbecker20
a417926690 1.15.11 allow adding stack to user group 2024-10-16 23:09:06 -04:00
mbecker20
293b36fae4 Allow adding Stack to User Group 2024-10-16 23:08:44 -04:00
mbecker20
dca37e9ba8 1.15.10 connect with http using DOCKER_HOST 2024-10-16 22:16:07 -04:00
Morgan Wyatt
1cc302fcbf Update docker.rs to allow http docker socket connection (#131)
* Update docker.rs to allow http docker socket connection

Add or_else to allow attempt to connect to docker socket proxy via http if local connection fails

* Update docker.rs

Change two part connection to use connect_with_defaults instead, per review on PR.
2024-10-16 19:13:19 -07:00
mbecker20
febcf739d0 Remove Comma from installer: thanks @PiotrBzdrega 2024-10-16 10:43:54 -04:00
mbecker20
cb79e00794 update systemd service file 2024-10-15 17:35:54 -04:00
mbecker20
869b397596 force service file recreation docs 2024-10-15 17:25:29 -04:00
Maxwell Becker
41d1ff9760 1.15.9 (#127)
* add close alert threshold to prevent Ok - Warning back and forth

* remove part about repo being deleted, no longer behavior

* resource sync share general common

* remove this changelog. use releases

* remove changelog from readme

* write commit file clean up path

* docs: supports any git provider repo

* fix docs: authorization

* multiline command supports escaped newlines

* move webhook to build config advanced

* parser comments with escaped newline

* improve parser

* save use Enter. escape monaco using escape

* improve logic when deployment / stack action buttons shown

* used_mem = total - available

* Fix unrecognized path have 404

* webhooks will 404 if misconfigured

* move update logger / alerter

* delete migrator

* update examples

* publish typescript client komodo_client
2024-10-14 23:04:49 -07:00
mbecker20
dfafadf57b demo / build username pw 2024-10-14 11:49:44 -04:00
mbecker20
538a79b8b5 fix upausing all container action state 2024-10-13 18:11:09 -04:00
Maxwell Becker
5088dc5c3c 1.15.8 (#124)
* fix all containers restart and unpause

* add CommitSync to Procedure

* validate resource query tags causes failure on non exist

* files on host init working. match tags fail if tag doesnt exist

* intelligent sync match tag selector

* fix linting

* Wait for user initialize file on host
2024-10-13 15:03:16 -07:00
mbecker20
581d7e0b2c fix Procedure sync log 2024-10-13 04:21:03 -04:00
mbecker20
657298041f remove unneeded syncs volume 2024-10-13 04:03:09 -04:00
mbecker20
d71e9dca11 fix version 2024-10-13 03:21:56 -04:00
Maxwell Becker
165131bdf8 1.15.7 (#119)
* 1.15.7-dev ensure git config set

* add username to commit msg
2024-10-13 00:01:14 -07:00
mbecker20
0a81d2a0d0 add labels to mongo compose 2024-10-13 00:57:13 -04:00
Maxwell Becker
44ab5eb804 1.15.6 (#117)
* add periphery.skip label, skip in StopAllContainers

* add core config sync directory

* deploy stack if changed

* fix stack env_file_path when git repo and using run_directory

* deploy stack if changed

* write sync contents

* commit to git based sync, managed git based sync

* can sync non UI defined resource syncs

* sync UI control

* clippy

* init new stack compose file in repo

* better error message when attached Server / Builder invalid

* specify multiple resource file paths (mixed files + folders)

* use react charts

* tweak stats charts

* add Containers page

* 1.15.6

* stack deploy check if deployes vs remote has changed

* improve ux with loading indicators

* sync diff accounts for deploy / after

* fix new chart time axes
2024-10-12 21:42:46 -07:00
Maxwell Becker
e3d8e603ec 1.15.5 (#116)
* 1.15.5
- Update your user's username and password
- **Admin**: Delete Users

* update username / password / delete user backend

* bump version

* alerter default disabled

* delete users and update username / password

* set password "" after update
2024-10-11 19:42:43 -07:00
mbecker20
8b5c179473 account recover note 2024-10-11 19:16:01 -04:00
mbecker20
8582bc92da fix Destroy Before Deploy config 2024-10-10 04:17:17 -04:00
Maxwell Becker
8ee270d045 1.15.4 (#114)
* stack destroy before deploy option

* add timestamps. Fix log polling even when poll not selected

* Add build [[$VERSION]] support. VERSION build arg default

* fix clippy lint

* initialize `first_builder`

* run_komodo_command uses parse_multiline_command

* comment UI for $VERSION and new command feature

* bump some deps

* support multiline commands in pre_deploy / pre_build
2024-10-10 00:37:23 -07:00
Maxwell Becker
2cfae525e9 1.15.3 (#109)
* fix parser support single quote '

* add stack reclone toggle

* git clone with token uses token:<TOKEN> for gitlab compatability

* support stack pre deploy shell command

* rename compose down update log stage

* deployment configure registry login account

* local testing setup

* bump version to 1.15.3

* new resources auto assign server if only one

* better error log when try to create resource with duplicate name

* end description with .

* ConfirmUpdate multi language

* fix compose write to host logic

* improve instrumentation

* improve update diff when small array

improve 2

* fix compose env file passing when repo_dir is not absolute
2024-10-08 23:07:38 -07:00
mbecker20
80e5d2a972 frontend dev setup guide 2024-10-08 16:55:24 -04:00
mbecker20
6f22c011a6 builder / server template add correct additional line if empty params 2024-10-07 22:55:48 -04:00
mbecker20
401cccee79 config nav buttons secondary 2024-10-07 21:55:14 -04:00
mbecker20
654b923f98 fix broken link to periphery setup 2024-10-07 18:56:14 -04:00
mbecker20
61261be70f update docs, split connecting servers out of Core Setup 2024-10-07 18:54:00 -04:00
mbecker20
46418125e3 update docs for periphery systemd --user install 2024-10-07 18:53:43 -04:00
mbecker20
e029e94f0d 1.15.2 Pass KOMODO_OIDC_ADDITIONAL_AUDIENCES 2024-10-07 15:44:51 -04:00
mbecker20
3be2b5163b 1.15.1 do not add trailing slash OIDC provider 2024-10-07 13:23:40 -04:00
mbecker20
6a145f58ff pass provider as-is. Authentik users should add a trailing slash 2024-10-07 13:16:25 -04:00
mbecker20
f1cede2ebd update dark / light stack screenshot to have action buttons 2024-10-07 08:05:39 -04:00
mbecker20
a5cfa1d412 update screenshots 2024-10-07 07:30:18 -04:00
mbecker20
a0674654c1 update screenshots 2024-10-07 07:30:11 -04:00
mbecker20
3faa1c58c1 update screenshots 2024-10-07 07:30:05 -04:00
mbecker20
7e296f34af screenshots 2024-10-07 07:29:58 -04:00
mbecker20
9f8ced190c update screenshots 2024-10-07 07:29:02 -04:00
mbecker20
c194bb16d8 update screenshots 2024-10-07 07:28:45 -04:00
mbecker20
39fec9b55e update screenshots 2024-10-07 07:27:52 -04:00
mbecker20
e97ed9888d update screenshots 1 2024-10-07 07:27:16 -04:00
mbecker20
559102ffe3 update readme 2024-10-07 07:25:36 -04:00
mbecker20
6bf80ddcc7 update screenshots readme 2024-10-07 07:25:24 -04:00
mbecker20
89dbe1b4d9 stack file_contents editor respects readOnly / disabled 2024-10-07 06:58:00 -04:00
mbecker20
334e16d646 OIDC use preferred username 2024-10-07 06:35:46 -04:00
mbecker20
a7bbe519f4 add build server link 2024-10-07 06:15:53 -04:00
mbecker20
5827486c5a add redirect uri for OIDC 2024-10-07 06:15:00 -04:00
mbecker20
8ca8f7eddd add context to oidc init error 2024-10-07 06:10:12 -04:00
mbecker20
0600276b43 fix parse KOMODO_MONGO_ in envs 2024-10-07 05:43:09 -04:00
mbecker20
a77a1495c7 active resources mb-12 not always there 2024-10-07 05:14:54 -04:00
mbecker20
021ed5d15f ActiveResources margin bottom 2024-10-07 03:24:57 -04:00
Maxwell Becker
7d4376f426 1.15.0 (#90)
* attach env_file to compose build and compose pull stages

* fmt and bump rust version

* bump dependencies

* ignored for Sqlite message

* fix Build secret args info

* improve secret arguments info

* improve environment, ports, volumes deserializers

* rename `mongo` to `database` in config

* support _FILE in secret env vars

* improve setup - simpler compose

* remove aws ecr container registry support, alpine dockerfiles

* log periphery config

* ssl_enabled mode

* log http vs https

* periphery client accept untrust ssl certs

* fix nav issue from links

* configurable ssl

* KOMODO_ENSURE_SERVER -> KOMODO_FIRST_SERVER

* mount proc and ssl volume

* managed sync

* validate files on host resource path

* remove sync repo not configured guards

* disable confirm dialog

* fix sync hash / message Option

* try dev dockerfile

* refresh sync resources after commit

* socket invalidate handling

* delete dev dockerfile

* Commit Changes

* Add Info tab to syncs

* fix new Info parsing issue with serde default

* refresh stack cache on create / update

* managed syncs can't sync themselves

* managed syncs seems to work

* bump thiserror

* use alpine as main dockerfile

* apt add --no-cache

* disable user write perms, super admin perms to manage admins

* manage admin user UI

* implement disable non admin create frontend

* disable create non admin

* Copy button shown based on permission

* warning message on managed sync

* implement monaco editor

* impl simple match tags config

* resource sync support match tags

* more match tag filtering

* improve config with better saving diffs

* export button use monaco

* deser Conversions with wrapping strings

* envs editing

* don't delete variables / user groups if match tags defined

* env from_str improve

* improve dashboards

* remove core ca stuff for now

* move periphery ssl gen to dedicated file

* default server address periphery:8120

* clean up ssl configs

* server dashboard

* nice test compose

* add discord alerter

* discord alerter

* stack hideInfo logic

* compose setup

* alert table

* improve config hover card style

* update min editor height and stack config

* Feat: Styling Updates (#94)

* sidebar takes full screen height

* add bg accent to navbar

* add aschild prop to topbar alerts trigger

* stylize resource rows

* internally scrollable data tables

* better hover color for outlined button

* always show scrollbar to prevent layout shift

* better hover color for navbar

* rearrange buttons

* fix table and resource row styles

* cleanup scrollbar css

* use page for dashboard instead of section

* fix padding

* resource sync refactor and env keep comments

* frontend build

* improve configs

* config nice

* Feat/UI (#95)

* stylize resource rows

* internally scrollable data tables

* fix table and resource row styles

* use page for dashboard instead of section

* fix padding

* add `ResourcePageHeader` to required components

* add generic resource page header component

* add resource page headers for all components

* add resource notificaitons component

* add `TextUpdateMenu2` for use in resource page

* cleanup resource notificaitons

* update resource page layout

* ui edits

* sync kind of work

* clean up unused import

* syncs seem to work

* new sync pending

* monaco diff hide unchanged regions

* update styling all in config  resource select links

* confirm update default strings

* move procedure Add Stage to left

* update colors / styles

* frontend build

* backend for write file contents to host

* compose reference ports comment out

* server config

* ensure parent directory created

* fix frontend build

* remove default stack run_directory

* fix periphery compose deploy response set

* update compose files

* move server stats under tabs

* fix deployment list item getting correct image when not deployed

* stack updates cache after file write

* edit files on host

* clean up unused imports

* top level config update assignment must be spread

* update deps, move alert module

* move stack module

* move sync module

* move to sync db_client usage after init

* support generic OIDC provider

* init builders / server templates specifying https

* special cases for server / deployment state

* improve alert details

* add builder template `use_https` config

* try downgrade aws sdk ec2 for x86 build

* update debian dockerfiles to rm lists/*

* optionally configure seperate KOMODO_OIDC_REDIRECT

* add defaults to compose.env

* keep tags / search right aligned when view only

* clean up configs

* remove unused migrator deps

* update roadmap support generic OIDC

* initialize sync use confirm button

* key_value syntax highlighting

* smaller debian dockerfiles

* clean up deps.sh

* debian dockerifle

* New config layout (#96)

* new config layout

* fix image config layout and components config

* fix dom nesting and cleanup components

* fix label, make switches flex row

* ensure smooth scroll on hash navigations

* width 180 on config sidebar

* slight edits to config

* log whether https builder

* DISABLED <switch> ENABLED

* fix some more config

* smaller checked component

* server config looking good

* auto initialize compose files when files on host

* stack files on host good

* stack config nice

* remove old config

* deployments looking good

* build looking good

* Repo good

* nice config for builders

* alerter good

* server template config

* syncs good

* tweak stack config

* use status badge for update tables

* unified update page using router params

* replace /updates with unified updates page

* redirect all resource updates to unified update page

* fix reset handling

* unmount legacy page

* try periphery rustls

* rm unused import

* fix broken deps

* add unified alerts apge

* mount new alerts, remove old alerts page

* reroute resource alerts to unified alerts page

* back to periphery openssl

* ssl_enabled defaults to false for backward compat

* reqwest need json feature

* back to og yaml monaco

* Uncomment config fields for clearer config

* clean up compose env

* implement pull or clone, avoid deleting repo directory

* refactor mongo configuration params

* all configs respect empty string null

* add back status to header

* build toml don't have version if not auto incrementing

* fix comile

* fix repo pull cd to correct dir

* fix core pull_or_clone directory

* improve statuses

* remove ' ' from kv list parser

* longer CSRF valid for, to give time to login / accept

* don't compute diff / execute if there are any file_errors

* PartialBuilderConfig enum user inner option

* move errors to top

* fix toml init serializer

* server template and bulder manually add config.params line

* better way to check builder / template params empty

* improve build configs

* merge links into network area deployment

* default periphery config

* improve SystemCommand editor

* better Repo server / builder Info

* improve Alerts / Updates with ResourceSelector

* fix unused frontend

* update ResourceSync description

* toml use [resource.config] syntax

* update toml syntax

* update Build.image_registry schema

* fix repo / stack resource link alias

* reorder image registry

* align toml / yaml parser style

* some config updates

---------

Co-authored-by: Karamvir Singh <67458484+karamvirsingh98@users.noreply.github.com>
Co-authored-by: kv <karamvir.singh98@gmail.com>
2024-10-06 23:54:23 -07:00
Brad Lugo
7e9b406a34 fix: change edit url for docsite (#91)
Changes the default edit url provided by the docusaurus template to the
correct komodo url.
2024-09-22 23:08:34 -07:00
Brad Lugo
dcf78b05b3 fix: check if systemd is available for install (#89)
Adds a check at the beginning of setup-periphery.py to verify if
`systemctl` is executable by the current user and if systemd is the
init system in use. These changes will inform the user we've decided
systemd is unavailable and exits before we attempt to use any systemd
functionality, but modify this logic to configure other init systems in
the future.

Relates to (but doesn't close)
https://github.com/mbecker20/komodo/issues/66.
2024-09-22 19:34:56 -07:00
Jérémy
3236302d05 Reduce periphery image size (#82)
* Reduce periphery image size

* rename new alpine Dockerfile as slim.Dockerfile

* bump slim dockerfile rust version

---------

Co-authored-by: mbecker20 <becker.maxh@gmail.com>
2024-09-21 14:57:51 -07:00
mbecker20
fc41258d6c fix docs deploy command --env-file 2024-09-13 12:12:01 +03:00
mbecker20
ae8df90361 uncomment compose envs set by variables for .env edit only 2024-09-12 11:11:28 +03:00
mbecker20
7d05b2677f explicit yaml 2024-09-12 00:52:35 +03:00
mbecker20
2f55468a4c simple docs 2024-09-11 23:13:42 +03:00
mbecker20
a20bd2c23f docker compose doc 2024-09-11 23:08:56 +03:00
mbecker20
b3aa0ffa78 remove unnecessary docs 2024-09-11 22:47:13 +03:00
mbecker20
8e58a283cd fix caddy compose 2024-09-11 22:07:06 +03:00
mbecker20
9b2d9932ef add prune buildx 2024-09-11 21:18:56 +03:00
mbecker20
7cb093ade1 fix links 2 2024-09-11 21:04:48 +03:00
mbecker20
e2f73d8474 fix broken doc links 2024-09-11 21:01:46 +03:00
Maxwell Becker
12abd5a5bd 1.14.2 (#70)
* docker builders / buildx prune backend

* seems to work with ferret

* improve UI error messages

* compose files

* update compose variables comment

* update compose files

* update sqlite compose

* env vars and others support end of line comment starting with " #"

* aws and hetzner default user data for hands free setup

* move configs

* new core config

* smth

* implement disable user registration

* clean up compose files

* add DISABLE_USER_REGISTRATION

* 1.14.2

* final
2024-09-11 10:50:59 -07:00
Maxwell Becker
f349cdf50d 1.14.1 (#69)
* 1.14.1

* 1.14.1 version

* repo pull use configured repo path

* don't show UI defined file if using Stack files on host mode

* Stack "run build" option

* note on bind mounts

* improve bind mount doc

* add links to schema

* add new stacks configs UI

* interp into stack build_extra_args

* add links UI
2024-09-10 08:17:53 -07:00
mbecker20
796bcac952 no business edition docs 2024-09-07 18:50:18 +03:00
mbecker20
fed05684aa no business edition 2024-09-07 18:48:58 +03:00
mbecker20
80a91584a8 version number link to releases 2024-09-07 12:54:54 +03:00
mbecker20
12d05e9a25 1.14.0 stable 2024-09-07 12:51:56 +03:00
mbecker20
f4d06c91ff add 5s log polling option 2024-09-07 12:42:35 +03:00
mbecker20
5d7449529f fix Builder schema link 2024-09-05 01:40:54 +03:00
mbecker20
a0021d1785 obfuscate secret variable value with * 2024-09-04 17:15:59 +03:00
mbecker20
bbd23e3f5f improve login failure feedback 2024-09-04 17:15:47 +03:00
mbecker20
71841a8e41 update komodo CLI readme 2024-09-04 16:48:27 +03:00
mbecker20
5228ffd9b8 fix changelog 2024-09-02 12:54:56 +03:00
mbecker20
a06f506e54 installer log 2024-09-02 03:13:58 +03:00
mbecker20
71d6a55e50 modified installer 2024-09-02 03:12:17 +03:00
mbecker20
d16c03dd2a fix force service file install 2024-09-02 03:09:14 +03:00
mbecker20
6abd9a6554 publish rc1 crates 2024-09-02 02:06:02 +03:00
mbecker20
5f04e881a5 fix doc link 2024-09-02 01:45:39 +03:00
Maxwell Becker
5fc0a87dea 1.14 - Rename to Komodo - Docker Management (#56)
* setup network page

* add Network, Image, Container

* Docker ListItems and Inspects

* frontend build

* dev0

* network info working

* fix cargo lock

* dev1

* pages for the things

* implement Active in dashboard

* RunBuild update trigger list refresh

* rename deployment executions to StartDeployment etc

* add server level container control

* dev2

* add Config field to Image

* can get image labels from Config.Labels

* mount container page

* server show resource count

* add GetContainerLog api

* add _AllContainers api

* dev3

* move ResourceTarget to entities mod

* GetResourceMatchingContainer api

* connect container to resource

* dev4 add volume names to container list items

* ts types

* volume / image / network unused management

* add image history to image page

* fix PruneContainers incorret Operation

* update cache for server for server after server actions

* dev5

* add singapore to Hetzner

* implement delete single network / image / volume api

* dev6

* include "in use" on Docker Lists

* add docker resource delete buttons

* is nice

* fix volume all in use

* remove google font dependency

* use host networking in test compose

* implement Secret Variables (hidden in logs)

* remove unneeded borrow

* interpolate variables / secrets into extra args / onclone / onpull / command etc

* validate empty strings before SelectItem

* rename everything to Komodo

* rename workspace to komodo

* rc1
2024-09-01 15:38:40 -07:00
853 changed files with 140552 additions and 61418 deletions

View File

@@ -0,0 +1,33 @@
services:
dev:
image: mcr.microsoft.com/devcontainers/rust:1-1-bullseye
volumes:
# Mount the root folder that contains .git
- ../:/workspace:cached
- /var/run/docker.sock:/var/run/docker.sock
- /proc:/proc
- repos:/etc/komodo/repos
- stacks:/etc/komodo/stacks
command: sleep infinity
ports:
- "9121:9121"
environment:
KOMODO_FIRST_SERVER: http://localhost:8120
KOMODO_DATABASE_ADDRESS: db
KOMODO_ENABLE_NEW_USERS: true
KOMODO_LOCAL_AUTH: true
KOMODO_JWT_SECRET: a_random_secret
links:
- db
# ...
db:
extends:
file: ../dev.compose.yaml
service: ferretdb
volumes:
data:
repo-cache:
repos:
stacks:

View File

@@ -0,0 +1,46 @@
// For format details, see https://aka.ms/devcontainer.json. For config options, see the
// README at: https://github.com/devcontainers/templates/tree/main/src/rust
{
"name": "Komodo",
// Or use a Dockerfile or Docker Compose file. More info: https://containers.dev/guide/dockerfile
//"image": "mcr.microsoft.com/devcontainers/rust:1-1-bullseye",
"dockerComposeFile": ["dev.compose.yaml"],
"workspaceFolder": "/workspace",
"service": "dev",
// Features to add to the dev container. More info: https://containers.dev/features.
"features": {
"ghcr.io/devcontainers/features/node:1": {
"version": "20.12.2"
},
"ghcr.io/devcontainers-community/features/deno:1": {
}
},
// Use 'mounts' to make the cargo cache persistent in a Docker Volume.
"mounts": [
{
"source": "devcontainer-cargo-cache-${devcontainerId}",
"target": "/usr/local/cargo",
"type": "volume"
}
],
// Use 'forwardPorts' to make a list of ports inside the container available locally.
"forwardPorts": [
9121
],
// Use 'postCreateCommand' to run commands after the container is created.
"postCreateCommand": "./.devcontainer/postCreate.sh",
"runServices": [
"db"
]
// Configure tool-specific properties.
// "customizations": {},
// Uncomment to connect as root instead. More info: https://aka.ms/dev-containers-non-root.
// "remoteUser": "root"
}

3
.devcontainer/postCreate.sh Executable file
View File

@@ -0,0 +1,3 @@
#!/bin/sh
cargo install typeshare-cli

View File

@@ -1,8 +0,0 @@
/target
readme.md
typeshare.toml
LICENSE
*.code-workspace
*/node_modules
*/dist

2
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1,2 @@
# https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/displaying-a-sponsor-button-in-your-repository
open_collective: komodo

14
.gitignore vendored
View File

@@ -1,11 +1,13 @@
target
/frontend/build
node_modules
/lib/ts_client/build
dist
deno.lock
.env
.env.development
creds.toml
core.config.toml
.syncs
.stacks
.DS_Store
.idea
/frontend/build
/lib/ts_client/build
.dev

1
.kminclude Normal file
View File

@@ -0,0 +1 @@
.dev

8
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"recommendations": [
"rust-lang.rust-analyzer",
"tamasfe.even-better-toml",
"vadimcn.vscode-lldb",
"denoland.vscode-deno"
]
}

View File

@@ -3,8 +3,8 @@
"scope": "rust",
"prefix": "resolve",
"body": [
"impl Resolve<${1}, User> for State {",
"\tasync fn resolve(&self, ${1} { ${0} }: ${1}, _: User) -> anyhow::Result<${2}> {",
"impl Resolve<${0}> for ${1} {",
"\tasync fn resolve(self, _: &${0}) -> Result<Self::Response, Self::Error> {",
"\t\ttodo!()",
"\t}",
"}"
@@ -15,9 +15,9 @@
"prefix": "static",
"body": [
"fn ${1}() -> &'static ${2} {",
"\tstatic ${3}: OnceLock<${2}> = OnceLock::new();",
"\t${3}.get_or_init(|| {",
"\t\t${0}",
"\tstatic ${0}: OnceLock<${2}> = OnceLock::new();",
"\t${0}.get_or_init(|| {",
"\t\ttodo!()",
"\t})",
"}"
]

272
.vscode/tasks.json vendored
View File

@@ -1,93 +1,179 @@
{
"version": "2.0.0",
"tasks": [
{
"type": "cargo",
"command": "build",
"group": {
"kind": "build",
"isDefault": true
},
"label": "rust: cargo build"
},
{
"type": "cargo",
"command": "fmt",
"label": "rust: cargo fmt"
},
{
"type": "cargo",
"command": "check",
"label": "rust: cargo check"
},
{
"label": "start dev",
"dependsOn": [
"run core",
"start frontend"
],
"problemMatcher": []
},
{
"type": "shell",
"command": "yarn start",
"label": "start frontend",
"options": {
"cwd": "${workspaceFolder}/frontend"
},
"presentation": {
"group": "start"
}
},
{
"type": "cargo",
"command": "run",
"label": "run core",
"options": {
"cwd": "${workspaceFolder}/bin/core"
},
"presentation": {
"group": "start"
}
},
{
"type": "cargo",
"command": "run",
"label": "run periphery",
"options": {
"cwd": "${workspaceFolder}/bin/periphery"
}
},
{
"type": "cargo",
"command": "run",
"label": "run tests",
"options": {
"cwd": "${workspaceFolder}/bin/tests"
}
},
{
"type": "cargo",
"command": "publish",
"args": ["--allow-dirty"],
"label": "publish types",
"options": {
"cwd": "${workspaceFolder}/lib/types"
}
},
{
"type": "cargo",
"command": "publish",
"label": "publish rs client",
"options": {
"cwd": "${workspaceFolder}/lib/rs_client"
}
},
{
"type": "shell",
"command": "node ./client/ts/generate_types.mjs",
"label": "generate typescript types",
"problemMatcher": []
}
]
}
{
"version": "2.0.0",
"tasks": [
{
"label": "Run Core",
"command": "cargo",
"args": [
"run",
"-p",
"komodo_core",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.core.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Build Core",
"command": "cargo",
"args": [
"build",
"-p",
"komodo_core",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.core.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Run Periphery",
"command": "cargo",
"args": [
"run",
"-p",
"komodo_periphery",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.periphery.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Build Periphery",
"command": "cargo",
"args": [
"build",
"-p",
"komodo_periphery",
"--release"
],
"options": {
"cwd": "${workspaceFolder}",
"env": {
"KOMODO_CONFIG_PATH": "test.periphery.config.toml"
}
},
"problemMatcher": [
"$rustc"
]
},
{
"label": "Run Backend",
"dependsOn": [
"Run Core",
"Run Periphery"
],
"problemMatcher": [
"$rustc"
]
},
{
"label": "Build TS Client Types",
"type": "process",
"command": "node",
"args": [
"./client/core/ts/generate_types.mjs"
],
"problemMatcher": []
},
{
"label": "Init TS Client",
"type": "shell",
"command": "yarn && yarn build && yarn link",
"options": {
"cwd": "${workspaceFolder}/client/core/ts",
},
"problemMatcher": []
},
{
"label": "Init Frontend Client",
"type": "shell",
"command": "yarn link komodo_client && yarn install",
"options": {
"cwd": "${workspaceFolder}/frontend",
},
"problemMatcher": []
},
{
"label": "Init Frontend",
"dependsOn": [
"Build TS Client Types",
"Init TS Client",
"Init Frontend Client"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Build Frontend",
"type": "shell",
"command": "yarn build",
"options": {
"cwd": "${workspaceFolder}/frontend",
},
"problemMatcher": []
},
{
"label": "Prepare Frontend For Run",
"type": "shell",
"command": "cp -r ./client/core/ts/dist/. frontend/public/client/.",
"options": {
"cwd": "${workspaceFolder}",
},
"dependsOn": [
"Build TS Client Types",
"Build Frontend"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Run Frontend",
"type": "shell",
"command": "yarn dev",
"options": {
"cwd": "${workspaceFolder}/frontend",
},
"dependsOn": ["Prepare Frontend For Run"],
"problemMatcher": []
},
{
"label": "Init",
"dependsOn": [
"Build Backend",
"Init Frontend"
],
"dependsOrder": "sequence",
"problemMatcher": []
},
{
"label": "Run Komodo",
"dependsOn": [
"Run Core",
"Run Periphery",
"Run Frontend"
],
"problemMatcher": []
},
]
}

5095
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,108 +1,149 @@
[workspace]
resolver = "2"
members = ["bin/*", "lib/*", "client/core/rs", "client/periphery/rs"]
members = [
"bin/*",
"lib/*",
"client/core/rs",
"client/periphery/rs",
]
[workspace.package]
version = "1.13.4"
edition = "2021"
version = "2.0.0-dev-72"
edition = "2024"
authors = ["mbecker20 <becker.maxh@gmail.com>"]
license = "GPL-3.0-or-later"
repository = "https://github.com/mbecker20/monitor"
homepage = "https://docs.monitor.dev"
repository = "https://github.com/moghtech/komodo"
homepage = "https://komo.do"
[patch.crates-io]
monitor_client = { path = "client/core/rs" }
[profile.release]
strip = "debuginfo"
[workspace.dependencies]
# LOCAL
monitor_client = "1.13.3"
komodo_client = { path = "client/core/rs" }
periphery_client = { path = "client/periphery/rs" }
environment_file = { path = "lib/environment_file" }
environment = { path = "lib/environment" }
interpolate = { path = "lib/interpolate" }
secret_file = { path = "lib/secret_file" }
formatting = { path = "lib/formatting" }
transport = { path = "lib/transport" }
database = { path = "lib/database" }
encoding = { path = "lib/encoding" }
response = { path = "lib/response" }
command = { path = "lib/command" }
config = { path = "lib/config" }
logger = { path = "lib/logger" }
cache = { path = "lib/cache" }
noise = { path = "lib/noise" }
git = { path = "lib/git" }
# MOGH
run_command = { version = "0.0.6", features = ["async_tokio"] }
serror = { version = "0.4.6", default-features = false }
slack = { version = "0.1.0", package = "slack_client_rs" }
serror = { version = "0.5.3", default-features = false }
slack = { version = "2.0.0", package = "slack_client_rs", default-features = false, features = ["rustls"] }
derive_default_builder = "0.1.8"
derive_empty_traits = "0.1.0"
merge_config_files = "0.1.5"
async_timing_util = "1.0.0"
async_timing_util = "1.1.0"
partial_derive2 = "0.4.3"
derive_variants = "1.0.0"
mongo_indexed = "2.0.1"
resolver_api = "1.1.1"
toml_pretty = "1.1.2"
mungos = "1.0.1"
svi = "1.0.1"
mongo_indexed = "2.0.2"
resolver_api = "3.0.0"
toml_pretty = "1.2.0"
mungos = "3.2.2"
svi = "1.2.0"
# ASYNC
tokio = { version = "1.39.2", features = ["full"] }
reqwest = { version = "0.12.5", features = ["json"] }
tokio-util = "0.7.11"
futures = "0.3.30"
futures-util = "0.3.30"
reqwest = { version = "0.12.24", default-features = false, features = ["json", "stream", "rustls-tls-native-roots"] }
tokio = { version = "1.48.0", features = ["full"] }
tokio-util = { version = "0.7.16", features = ["io", "codec"] }
tokio-stream = { version = "0.1.17", features = ["sync"] }
pin-project-lite = "0.2.16"
futures-util = "0.3.31"
arc-swap = "1.7.1"
# SERVER
axum = { version = "0.7.5", features = ["ws", "json"] }
axum-extra = { version = "0.9.3", features = ["typed-header"] }
tower-http = { version = "0.5.2", features = ["fs", "cors"] }
tokio-tungstenite = "0.23.1"
tokio-tungstenite = { version = "0.28.0", features = ["rustls-tls-native-roots"] }
axum-extra = { version = "0.10.3", features = ["typed-header"] }
tower-http = { version = "0.6.6", features = ["fs", "cors"] }
axum-server = { version = "0.7.2", features = ["tls-rustls"] }
axum = { version = "0.8.6", features = ["ws", "json", "macros"] }
# SER/DE
ordered_hash_map = { version = "0.4.0", features = ["serde"] }
serde = { version = "1.0.208", features = ["derive"] }
strum = { version = "0.26.3", features = ["derive"] }
serde_json = "1.0.125"
serde_yaml = "0.9.34"
toml = "0.8.19"
ipnetwork = { version = "0.21.1", features = ["serde"] }
indexmap = { version = "2.12.0", features = ["serde"] }
serde = { version = "1.0.227", features = ["derive"] }
strum = { version = "0.27.2", features = ["derive"] }
bson = { version = "2.15.0" } # must keep in sync with mongodb version
serde_yaml_ng = "0.10.0"
serde_json = "1.0.145"
serde_qs = "0.15.0"
toml = "0.9.8"
url = "2.5.7"
# ERROR
anyhow = "1.0.86"
thiserror = "1.0.63"
anyhow = "1.0.100"
thiserror = "2.0.17"
# LOGGING
opentelemetry_sdk = { version = "0.24.1", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.18", features = ["json"] }
opentelemetry-semantic-conventions = "0.16.0"
tracing-opentelemetry = "0.25.0"
opentelemetry-otlp = "0.17.0"
opentelemetry = "0.24.0"
tracing = "0.1.40"
opentelemetry-otlp = { version = "0.31.0", features = ["tls-roots", "reqwest-rustls"] }
opentelemetry_sdk = { version = "0.31.0", features = ["rt-tokio"] }
tracing-subscriber = { version = "0.3.20", features = ["json"] }
opentelemetry-semantic-conventions = "0.31.0"
tracing-opentelemetry = "0.32.0"
opentelemetry = "0.31.0"
tracing = "0.1.41"
# CONFIG
clap = { version = "4.5.16", features = ["derive"] }
clap = { version = "4.5.49", features = ["derive"] }
dotenvy = "0.15.7"
envy = "0.4.2"
# CRYPTO
uuid = { version = "1.10.0", features = ["v4", "fast-rng", "serde"] }
# CRYPTO / AUTH
uuid = { version = "1.18.1", features = ["v4", "fast-rng", "serde"] }
jsonwebtoken = { version = "10.0.0", features = ["aws_lc_rs"] } # locked back with octorust
rustls = { version = "0.23.33", features = ["aws-lc-rs"] }
pem-rfc7468 = { version = "0.7.0", features = ["alloc"] }
openidconnect = "4.0.1"
urlencoding = "2.1.3"
nom_pem = "4.0.0"
bcrypt = "0.15.1"
bcrypt = "0.17.1"
base64 = "0.22.1"
pkcs8 = "0.10.2"
snow = "0.10.0"
hmac = "0.12.1"
sha2 = "0.10.8"
rand = "0.8.5"
jwt = "0.16.0"
sha1 = "0.10.6"
sha2 = "0.10.9"
rand = "0.9.2"
hex = "0.4.3"
spki = "0.7.3"
der = "0.7.10"
# SYSTEM
bollard = "0.17.0"
sysinfo = "0.31.2"
hickory-resolver = "0.25.2"
portable-pty = "0.9.0"
shell-escape = "0.1.5"
crossterm = "0.29.0"
bollard = "0.19.3"
sysinfo = "0.37.1"
# CLOUD
aws-config = "1.5.5"
aws-sdk-ec2 = "1.66.0"
aws-sdk-ecr = "1.40.0"
aws-config = "1.8.8"
aws-sdk-ec2 = "1.175.1"
aws-credential-types = "1.2.8"
## CRON
english-to-cron = "0.1.6"
chrono-tz = "0.10.4"
chrono = "0.4.42"
croner = "3.0.0"
# MISC
derive_builder = "0.20.0"
typeshare = "1.0.3"
octorust = "0.7.0"
colored = "2.1.0"
regex = "1.10.6"
bson = "2.11.0"
async-compression = { version = "0.4.32", features = ["tokio", "gzip"] }
derive_builder = "0.20.2"
comfy-table = "7.2.1"
typeshare = "1.0.4"
dashmap = "6.1.0"
wildcard = "0.3.0"
colored = "3.0.0"
regex = "1.12.2"
bytes = "1.10.1"

2
action/build.ts Normal file
View File

@@ -0,0 +1,2 @@
import { run } from "./run.ts";
await run("build-komodo");

5
action/deno.json Normal file
View File

@@ -0,0 +1,5 @@
{
"imports": {
"@std/toml": "jsr:@std/toml"
}
}

4
action/deploy-fe.ts Normal file
View File

@@ -0,0 +1,4 @@
const cmd = "km run -y action deploy-komodo-fe-change";
new Deno.Command("bash", {
args: ["-c", cmd],
}).spawn();

2
action/deploy.ts Executable file
View File

@@ -0,0 +1,2 @@
import { run } from "./run.ts";
await run("deploy-komodo");

52
action/run.ts Normal file
View File

@@ -0,0 +1,52 @@
import * as TOML from "@std/toml";
export const run = async (action: string) => {
const branch = await new Deno.Command("bash", {
args: ["-c", "git rev-parse --abbrev-ref HEAD"],
})
.output()
.then((r) => new TextDecoder("utf-8").decode(r.stdout).trim());
const cargo_toml_str = await Deno.readTextFile("Cargo.toml");
const prev_version = (
TOML.parse(cargo_toml_str) as {
workspace: { package: { version: string } };
}
).workspace.package.version;
const [version, tag, count] = prev_version.split("-");
const next_count = Number(count) + 1;
const next_version = `${version}-${tag}-${next_count}`;
await Deno.writeTextFile(
"Cargo.toml",
cargo_toml_str.replace(
`version = "${prev_version}"`,
`version = "${next_version}"`
)
);
// Cargo check first here to make sure lock file is updated before commit.
const cmd = `
cargo check
echo ""
git add --all
git commit --all --message "deploy ${version}-${tag}-${next_count}"
echo ""
git push
echo ""
km run -y action ${action} "KOMODO_BRANCH=${branch}&KOMODO_VERSION=${version}&KOMODO_TAG=${tag}-${next_count}"
`
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("//"))
.join(" && ");
new Deno.Command("bash", {
args: ["-c", cmd],
}).spawn();
};

View File

@@ -1,4 +0,0 @@
# Alerter
This crate sets up a basic axum server that listens for incoming alert POSTs.
It can be used as a monitor alerting endpoint, and serves as a template for other custom alerter implementations.

32
bin/binaries.Dockerfile Normal file
View File

@@ -0,0 +1,32 @@
## Builds the Komodo Core, Periphery, and Util binaries
## for a specific architecture.
FROM rust:1.90.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
COPY ./lib ./lib
COPY ./client/core/rs ./client/core/rs
COPY ./client/periphery ./client/periphery
COPY ./bin/core ./bin/core
COPY ./bin/periphery ./bin/periphery
COPY ./bin/cli ./bin/cli
# Compile bin
RUN \
cargo build -p komodo_core --release && \
cargo build -p komodo_periphery --release && \
cargo build -p komodo_cli --release && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch
COPY --from=builder /builder/target/release/core /core
COPY --from=builder /builder/target/release/periphery /periphery
COPY --from=builder /builder/target/release/km /km
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo Binaries"
LABEL org.opencontainers.image.licenses="GPL-3.0"

View File

@@ -0,0 +1,36 @@
## Builds the Komodo Core, Periphery, and Util binaries
## for a specific architecture.
## Uses chef for dependency caching to help speed up back-to-back builds.
FROM lukemathwalker/cargo-chef:latest-rust-1.90.0-bullseye AS chef
WORKDIR /builder
# Plan just the RECIPE to see if things have changed
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
RUN cargo install cargo-strip
COPY --from=planner /builder/recipe.json recipe.json
# Build JUST dependencies - cached layer
RUN cargo chef cook --release --recipe-path recipe.json
# NOW copy again (this time into builder) and build app
COPY . .
RUN \
cargo build --release --bin core && \
cargo build --release --bin periphery && \
cargo build --release --bin km && \
cargo strip
# Copy just the binaries to scratch image
FROM scratch
COPY --from=builder /builder/target/release/core /core
COPY --from=builder /builder/target/release/periphery /periphery
COPY --from=builder /builder/target/release/km /km
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo Binaries"
LABEL org.opencontainers.image.licenses="GPL-3.0"

View File

@@ -1,29 +1,40 @@
[package]
name = "monitor_cli"
description = "Command line tool to sync monitor resources and execute file defined procedures"
name = "komodo_cli"
description = "Command line tool for Komodo"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
homepage.workspace = true
repository.workspace = true
homepage.workspace = true
[[bin]]
name = "monitor"
name = "km"
path = "src/main.rs"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# local
monitor_client.workspace = true
environment_file.workspace = true
komodo_client.workspace = true
database.workspace = true
config.workspace = true
logger.workspace = true
noise.workspace = true
# external
tracing-subscriber.workspace = true
merge_config_files.workspace = true
futures.workspace = true
tokio-tungstenite.workspace = true
futures-util.workspace = true
comfy-table.workspace = true
serde_json.workspace = true
crossterm.workspace = true
serde_qs.workspace = true
wildcard.workspace = true
tracing.workspace = true
colored.workspace = true
dotenvy.workspace = true
anyhow.workspace = true
chrono.workspace = true
bytes.workspace = true
tokio.workspace = true
serde.workspace = true
clap.workspace = true
envy.workspace = true

View File

@@ -1,11 +1,11 @@
# Monitor CLI
# Komodo CLI
Monitor CLI is a tool to sync monitor resources and execute operations.
Komodo CLI is a tool to execute actions on your Komodo instance from shell scripts.
## Install
```sh
cargo install monitor_cli
cargo install komodo_cli
```
Note: On Ubuntu, also requires `apt install build-essential pkg-config libssl-dev`.
@@ -14,9 +14,9 @@ Note: On Ubuntu, also requires `apt install build-essential pkg-config libssl-de
### Credentials
Configure a file `~/.config/monitor/creds.toml` file with contents:
Configure a file `~/.config/komodo/creds.toml` file with contents:
```toml
url = "https://your.monitor.address"
url = "https://your.komodo.address"
key = "YOUR-API-KEY"
secret = "YOUR-API-SECRET"
```
@@ -25,52 +25,88 @@ Note. You can specify a different creds file by using `--creds ./other/path.toml
You can also bypass using any file and pass the information using `--url`, `--key`, `--secret`:
```sh
monitor --url "https://your.monitor.address" --key "YOUR-API-KEY" --secret "YOUR-API-SECRET" ...
komodo --url "https://your.komodo.address" --key "YOUR-API-KEY" --secret "YOUR-API-SECRET" ...
```
### Run Executions
```sh
# Triggers an example build
monitor execute run-build test_build
komodo execute run-build test_build
```
#### Manual
`komodo --help`
```md
Command line tool to execute Komodo actions
Usage: komodo [OPTIONS] <COMMAND>
Commands:
execute Runs an execution
help Print this message or the help of the given subcommand(s)
Options:
--creds <CREDS> The path to a creds file [default: /Users/max/.config/komodo/creds.toml]
--url <URL> Pass url in args instead of creds file
--key <KEY> Pass api key in args instead of creds file
--secret <SECRET> Pass api secret in args instead of creds file
-y, --yes Always continue on user confirmation prompts
-h, --help Print help (see more with '--help')
-V, --version Print version
```
`komodo execute --help`
```md
Runs an execution
Usage: monitor execute <COMMAND>
Usage: komodo execute <COMMAND>
Commands:
none The "null" execution. Does nothing
run-procedure Runs the target procedure. Response: [Update]
run-build Runs the target build. Response: [Update]
cancel-build Cancels the target build. Only does anything if the build is `building` when called. Response: [Update]
deploy Deploys the container for the target deployment. Response: [Update]
start-container Starts the container for the target deployment. Response: [Update]
restart-container Restarts the container for the target deployment. Response: [Update]
pause-container Pauses the container for the target deployment. Response: [Update]
unpause-container Unpauses the container for the target deployment. Response: [Update]
stop-container Stops the container for the target deployment. Response: [Update]
remove-container Stops and removes the container for the target deployment. Reponse: [Update]
clone-repo Clones the target repo. Response: [Update]
pull-repo Pulls the target repo. Response: [Update]
build-repo Builds the target repo, using the attached builder. Response: [Update]
cancel-repo-build Cancels the target repo build. Only does anything if the repo build is `building` when called. Response: [Update]
stop-all-containers Stops all containers on the target server. Response: [Update]
prune-networks Prunes the docker networks on the target server. Response: [Update]
prune-images Prunes the docker images on the target server. Response: [Update]
prune-containers Prunes the docker containers on the target server. Response: [Update]
run-sync Runs the target resource sync. Response: [Update]
deploy-stack Deploys the target stack. `docker compose up`. Response: [Update]
start-stack Starts the target stack. `docker compose start`. Response: [Update]
restart-stack Restarts the target stack. `docker compose restart`. Response: [Update]
pause-stack Pauses the target stack. `docker compose pause`. Response: [Update]
unpause-stack Unpauses the target stack. `docker compose unpause`. Response: [Update]
stop-stack Starts the target stack. `docker compose stop`. Response: [Update]
destroy-stack Destoys the target stack. `docker compose down`. Response: [Update]
sleep
help Print this message or the help of the given subcommand(s)
none The "null" execution. Does nothing
run-procedure Runs the target procedure. Response: [Update]
run-build Runs the target build. Response: [Update]
cancel-build Cancels the target build. Only does anything if the build is `building` when called. Response: [Update]
deploy Deploys the container for the target deployment. Response: [Update]
start-deployment Starts the container for the target deployment. Response: [Update]
restart-deployment Restarts the container for the target deployment. Response: [Update]
pause-deployment Pauses the container for the target deployment. Response: [Update]
unpause-deployment Unpauses the container for the target deployment. Response: [Update]
stop-deployment Stops the container for the target deployment. Response: [Update]
destroy-deployment Stops and destroys the container for the target deployment. Reponse: [Update]
clone-repo Clones the target repo. Response: [Update]
pull-repo Pulls the target repo. Response: [Update]
build-repo Builds the target repo, using the attached builder. Response: [Update]
cancel-repo-build Cancels the target repo build. Only does anything if the repo build is `building` when called. Response: [Update]
start-container Starts the container on the target server. Response: [Update]
restart-container Restarts the container on the target server. Response: [Update]
pause-container Pauses the container on the target server. Response: [Update]
unpause-container Unpauses the container on the target server. Response: [Update]
stop-container Stops the container on the target server. Response: [Update]
destroy-container Stops and destroys the container on the target server. Reponse: [Update]
start-all-containers Starts all containers on the target server. Response: [Update]
restart-all-containers Restarts all containers on the target server. Response: [Update]
pause-all-containers Pauses all containers on the target server. Response: [Update]
unpause-all-containers Unpauses all containers on the target server. Response: [Update]
stop-all-containers Stops all containers on the target server. Response: [Update]
prune-containers Prunes the docker containers on the target server. Response: [Update]
delete-network Delete a docker network. Response: [Update]
prune-networks Prunes the docker networks on the target server. Response: [Update]
delete-image Delete a docker image. Response: [Update]
prune-images Prunes the docker images on the target server. Response: [Update]
delete-volume Delete a docker volume. Response: [Update]
prune-volumes Prunes the docker volumes on the target server. Response: [Update]
prune-system Prunes the docker system on the target server, including volumes. Response: [Update]
run-sync Runs the target resource sync. Response: [Update]
deploy-stack Deploys the target stack. `docker compose up`. Response: [Update]
start-stack Starts the target stack. `docker compose start`. Response: [Update]
restart-stack Restarts the target stack. `docker compose restart`. Response: [Update]
pause-stack Pauses the target stack. `docker compose pause`. Response: [Update]
unpause-stack Unpauses the target stack. `docker compose unpause`. Response: [Update]
stop-stack Starts the target stack. `docker compose stop`. Response: [Update]
destroy-stack Destoys the target stack. `docker compose down`. Response: [Update]
sleep
help Print this message or the help of the given subcommand(s)
Options:
-h, --help Print help

25
bin/cli/aio.Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM rust:1.90.0-bullseye AS builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
COPY ./lib ./lib
COPY ./client/core/rs ./client/core/rs
COPY ./client/periphery ./client/periphery
COPY ./bin/cli ./bin/cli
# Compile bin
RUN cargo build -p komodo_cli --release && cargo strip
# Copy binaries to distroless base
FROM gcr.io/distroless/cc
COPY --from=builder /builder/target/release/km /usr/local/bin/km
ENV KOMODO_CLI_CONFIG_PATHS="/config"
CMD [ "km" ]
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo CLI"
LABEL org.opencontainers.image.licenses="GPL-3.0"

View File

@@ -0,0 +1,135 @@
# Copy Database Utility
Copy the Komodo database contents between running, mongo-compatible databases.
Can be used to move between MongoDB / FerretDB, or upgrade from FerretDB v1 to v2.
```yaml
services:
copy_database:
image: ghcr.io/moghtech/komodo-cli
command: km database copy -y
environment:
KOMODO_DATABASE_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@source:27017
KOMODO_DATABASE_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo}
KOMODO_CLI_DATABASE_TARGET_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@target:27017
KOMODO_CLI_DATABASE_TARGET_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo}
```
## FerretDB v2 Update Guide
Up to Komodo 1.17.5, users who wanted to use Postgres / Sqlite were instructed to deploy FerretDB v1.
Now that v2 is out however, v1 will go largely unsupported. Users are recommended to migrate to v2 for
the best performance and ongoing support / updates, however the internal data structures
have changed and this cannot be done in-place.
Also note that FerretDB v2 no longer supports Sqlite, and only supports
a [customized Postgres distribution](https://docs.ferretdb.io/installation/documentdb/docker/).
Nonetheless, it remains a solid option for hosts which [do not support mongo](https://github.com/moghtech/komodo/issues/59).
Also note, the same basic process outlined below can also be used to move between MongoDB and FerretDB, just replace FerretDB v2
with the database you wish to move to.
### **Step 1**: *Add* the new database to the top of your existing Komodo compose file.
**Don't forget to also add the new volumes.**
```yaml
## In Komodo compose.yaml
services:
postgres2:
# Recommended: Pin to a specific version
# https://github.com/FerretDB/documentdb/pkgs/container/postgres-documentdb
image: ghcr.io/ferretdb/postgres-documentdb
labels:
komodo.skip: # Prevent Komodo from stopping with StopAllContainers
restart: unless-stopped
# ports:
# - 5432:5432
volumes:
- postgres-data:/var/lib/postgresql/data
environment:
POSTGRES_USER: ${KOMODO_DB_USERNAME}
POSTGRES_PASSWORD: ${KOMODO_DB_PASSWORD}
POSTGRES_DB: postgres # Do not change
ferretdb2:
# Recommended: Pin to a specific version
# https://github.com/FerretDB/FerretDB/pkgs/container/ferretdb
image: ghcr.io/ferretdb/ferretdb
labels:
komodo.skip: # Prevent Komodo from stopping with StopAllContainers
restart: unless-stopped
depends_on:
- postgres2
# ports:
# - 27017:27017
volumes:
- ferretdb-state:/state
environment:
FERRETDB_POSTGRESQL_URL: postgres://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@postgres2:5432/postgres
...(unchanged)
volumes:
...(unchanged)
postgres-data:
ferretdb-state:
```
### **Step 2**: *Add* the database copy utility to Komodo compose file.
The SOURCE_URI points to the existing database, ie the old FerretDB v1, and it depends
on whether it was deployed using Postgres or Sqlite. The example below uses the Postgres one,
but if you use Sqlite it should just be something like `mongodb://ferretdb:27017`.
```yaml
## In Komodo compose.yaml
services:
...(new database)
copy_database:
image: ghcr.io/moghtech/komodo-cli
command: km database copy -y
environment:
KOMODO_DATABASE_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@ferretdb:27017/${KOMODO_DATABASE_DB_NAME:-komodo}?authMechanism=PLAIN
KOMODO_DATABASE_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo}
KOMODO_CLI_DATABASE_TARGET_URI: mongodb://${KOMODO_DB_USERNAME}:${KOMODO_DB_PASSWORD}@ferretdb2:27017
KOMODO_CLI_DATABASE_TARGET_DB_NAME: ${KOMODO_DATABASE_DB_NAME:-komodo}
...(unchanged)
```
### **Step 3**: *Compose Up* the new additions
Run `docker compose -p komodo --env-file compose.env -f xxxxx.compose.yaml up -d`, filling in the name of your compose.yaml.
This will start up both the old and new database, and copy the data to the new one.
Wait a few moments for the `copy_database` service to finish. When it exits,
confirm the logs show the data was moved successfully, and move on to the next step.
### **Step 4**: Point Komodo Core to the new database
In your Komodo compose.yaml, first *comment out* the `copy_database` service and old ferretdb v1 service/s.
Then update the `core` service environment to point to `ferretdb2`.
```yaml
services:
...
core:
...(unchanged)
environment:
KOMODO_DATABASE_ADDRESS: ferretdb2:27017
KOMODO_DATABASE_USERNAME: ${KOMODO_DB_USERNAME}
KOMODO_DATABASE_PASSWORD: ${KOMODO_DB_PASSWORD}
```
### **Step 5**: Final *Compose Up*
Repeat the same `docker compose` command as before to apply the changes, and then try navigating to your Komodo web page.
If it works, congrats, **you are done**. You can clean up the compose file if you would like, removing the old volumes etc.
If it does not work, check the logs for any obvious issues, and if necessary you can undo the previous steps
to go back to using the previous database.

View File

@@ -0,0 +1,29 @@
## Assumes the latest binaries for x86_64 and aarch64 are already built (by binaries.Dockerfile).
## Since theres no heavy build here, QEMU multi-arch builds are fine for this image.
ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest
ARG X86_64_BINARIES=${BINARIES_IMAGE}-x86_64
ARG AARCH64_BINARIES=${BINARIES_IMAGE}-aarch64
# This is required to work with COPY --from
FROM ${X86_64_BINARIES} AS x86_64
FROM ${AARCH64_BINARIES} AS aarch64
FROM debian:bullseye-slim
WORKDIR /app
## Copy both binaries initially, but only keep appropriate one for the TARGETPLATFORM.
COPY --from=x86_64 /km /app/arch/linux/amd64
COPY --from=aarch64 /km /app/arch/linux/arm64
ARG TARGETPLATFORM
RUN mv /app/arch/${TARGETPLATFORM} /usr/local/bin/km && rm -r /app/arch
ENV KOMODO_CLI_CONFIG_PATHS="/config"
CMD [ "km" ]
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo CLI"
LABEL org.opencontainers.image.licenses="GPL-3.0"

4
bin/cli/runfile.toml Normal file
View File

@@ -0,0 +1,4 @@
[install-cli]
alias = "ic"
description = "installs the komodo-cli, available on the command line as 'km'"
cmd = "cargo install --path ."

View File

@@ -0,0 +1,18 @@
## Assumes the latest binaries for the required arch are already built (by binaries.Dockerfile).
ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest
# This is required to work with COPY --from
FROM ${BINARIES_IMAGE} AS binaries
FROM gcr.io/distroless/cc
COPY --from=binaries /km /usr/local/bin/km
ENV KOMODO_CLI_CONFIG_PATHS="/config"
CMD [ "km" ]
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo CLI"
LABEL org.opencontainers.image.licenses="GPL-3.0"

View File

@@ -1,55 +0,0 @@
use clap::{Parser, Subcommand};
use monitor_client::api::execute::Execution;
use serde::Deserialize;
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
pub struct CliArgs {
/// Sync or Exec
#[command(subcommand)]
pub command: Command,
/// The path to a creds file.
///
/// Note: If each of `url`, `key` and `secret` are passed,
/// no file is required at this path.
#[arg(long, default_value_t = default_creds())]
pub creds: String,
/// Pass url in args instead of creds file
#[arg(long)]
pub url: Option<String>,
/// Pass api key in args instead of creds file
#[arg(long)]
pub key: Option<String>,
/// Pass api secret in args instead of creds file
#[arg(long)]
pub secret: Option<String>,
/// Always continue on user confirmation prompts.
#[arg(long, short, default_value_t = false)]
pub yes: bool,
}
fn default_creds() -> String {
let home =
std::env::var("HOME").unwrap_or_else(|_| String::from("/root"));
format!("{home}/.config/monitor/creds.toml")
}
#[derive(Debug, Clone, Subcommand)]
pub enum Command {
/// Runs an execution
Execute {
#[command(subcommand)]
execution: Execution,
},
// Room for more
}
#[derive(Debug, Deserialize)]
pub struct CredsFile {
pub url: String,
pub key: String,
pub secret: String,
}

View File

@@ -0,0 +1,312 @@
use std::collections::{HashMap, HashSet};
use anyhow::Context;
use colored::Colorize;
use comfy_table::{Attribute, Cell, Color};
use futures_util::{
FutureExt, TryStreamExt, stream::FuturesUnordered,
};
use komodo_client::{
api::read::{
InspectDockerContainer, ListAllDockerContainers, ListServers,
},
entities::{
config::cli::args::container::{
Container, ContainerCommand, InspectContainer,
},
docker::{
self,
container::{ContainerListItem, ContainerStateStatusEnum},
},
},
};
use crate::{
command::{
PrintTable, clamp_sha, matches_wildcards, parse_wildcards,
print_items,
},
config::cli_config,
};
pub async fn handle(container: &Container) -> anyhow::Result<()> {
match &container.command {
None => list_containers(container).await,
Some(ContainerCommand::Inspect(inspect)) => {
inspect_container(inspect).await
}
}
}
async fn list_containers(
Container {
all,
down,
links,
reverse,
containers: names,
images,
networks,
servers,
format,
command: _,
}: &Container,
) -> anyhow::Result<()> {
let client = super::komodo_client().await?;
let (server_map, containers) = tokio::try_join!(
client
.read(ListServers::default())
.map(|res| res.map(|res| res
.into_iter()
.map(|s| (s.id.clone(), s))
.collect::<HashMap<_, _>>())),
client.read(ListAllDockerContainers {
servers: Default::default()
}),
)?;
// (Option<Server Name>, Container)
let containers = containers.into_iter().map(|c| {
let server = if let Some(server_id) = c.server_id.as_ref()
&& let Some(server) = server_map.get(server_id)
{
server
} else {
return (None, c);
};
(Some(server.name.as_str()), c)
});
let names = parse_wildcards(names);
let servers = parse_wildcards(servers);
let images = parse_wildcards(images);
let networks = parse_wildcards(networks);
let mut containers = containers
.into_iter()
.filter(|(server_name, c)| {
let state_check = if *all {
true
} else if *down {
!matches!(c.state, ContainerStateStatusEnum::Running)
} else {
matches!(c.state, ContainerStateStatusEnum::Running)
};
let network_check = matches_wildcards(
&networks,
&c.network_mode
.as_deref()
.map(|n| vec![n])
.unwrap_or_default(),
) || matches_wildcards(
&networks,
&c.networks.iter().map(String::as_str).collect::<Vec<_>>(),
);
state_check
&& network_check
&& matches_wildcards(&names, &[c.name.as_str()])
&& matches_wildcards(
&servers,
&server_name
.as_deref()
.map(|i| vec![i])
.unwrap_or_default(),
)
&& matches_wildcards(
&images,
&c.image.as_deref().map(|i| vec![i]).unwrap_or_default(),
)
})
.collect::<Vec<_>>();
containers.sort_by(|(a_s, a), (b_s, b)| {
a.state
.cmp(&b.state)
.then(a.name.cmp(&b.name))
.then(a_s.cmp(b_s))
.then(a.network_mode.cmp(&b.network_mode))
.then(a.image.cmp(&b.image))
});
if *reverse {
containers.reverse();
}
print_items(containers, *format, *links)?;
Ok(())
}
pub async fn inspect_container(
inspect: &InspectContainer,
) -> anyhow::Result<()> {
let client = super::komodo_client().await?;
let (server_map, mut containers) = tokio::try_join!(
client
.read(ListServers::default())
.map(|res| res.map(|res| res
.into_iter()
.map(|s| (s.id.clone(), s))
.collect::<HashMap<_, _>>())),
client.read(ListAllDockerContainers {
servers: Default::default()
}),
)?;
containers.iter_mut().for_each(|c| {
let Some(server_id) = c.server_id.as_ref() else {
return;
};
let Some(server) = server_map.get(server_id) else {
c.server_id = Some(String::from("Unknown"));
return;
};
c.server_id = Some(server.name.clone());
});
let names = [inspect.container.to_string()];
let names = parse_wildcards(&names);
let servers = parse_wildcards(&inspect.servers);
let mut containers = containers
.into_iter()
.filter(|c| {
matches_wildcards(&names, &[c.name.as_str()])
&& matches_wildcards(
&servers,
&c.server_id
.as_deref()
.map(|i| vec![i])
.unwrap_or_default(),
)
})
.map(|c| async move {
client
.read(InspectDockerContainer {
container: c.name,
server: c.server_id.context("No server...")?,
})
.await
})
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await?;
containers.sort_by(|a, b| a.name.cmp(&b.name));
match containers.len() {
0 => {
println!(
"{}: Did not find any containers matching '{}'",
"INFO".green(),
inspect.container.bold()
);
}
1 => {
println!("{}", serialize_container(inspect, &containers[0])?);
}
_ => {
let containers = containers
.iter()
.map(|c| serialize_container(inspect, c))
.collect::<anyhow::Result<Vec<_>>>()?
.join("\n");
println!("{containers}");
}
}
Ok(())
}
fn serialize_container(
inspect: &InspectContainer,
container: &docker::container::Container,
) -> anyhow::Result<String> {
let res = if inspect.state {
serde_json::to_string_pretty(&container.state)
} else if inspect.mounts {
serde_json::to_string_pretty(&container.mounts)
} else if inspect.host_config {
serde_json::to_string_pretty(&container.host_config)
} else if inspect.config {
serde_json::to_string_pretty(&container.config)
} else if inspect.network_settings {
serde_json::to_string_pretty(&container.network_settings)
} else {
serde_json::to_string_pretty(container)
}
.context("Failed to serialize items to JSON")?;
Ok(res)
}
// (Option<Server Name>, Container)
impl PrintTable for (Option<&'_ str>, ContainerListItem) {
fn header(links: bool) -> &'static [&'static str] {
if links {
&[
"Container",
"State",
"Server",
"Ports",
"Networks",
"Image",
"Link",
]
} else {
&["Container", "State", "Server", "Ports", "Networks", "Image"]
}
}
fn row(self, links: bool) -> Vec<Cell> {
let color = match self.1.state {
ContainerStateStatusEnum::Running => Color::Green,
ContainerStateStatusEnum::Paused => Color::DarkYellow,
ContainerStateStatusEnum::Empty => Color::Grey,
_ => Color::Red,
};
let mut networks = HashSet::new();
if let Some(network) = self.1.network_mode {
networks.insert(network);
}
for network in self.1.networks {
networks.insert(network);
}
let mut networks = networks.into_iter().collect::<Vec<_>>();
networks.sort();
let mut ports = self
.1
.ports
.into_iter()
.flat_map(|p| p.public_port.map(|p| p.to_string()))
.collect::<HashSet<_>>()
.into_iter()
.collect::<Vec<_>>();
ports.sort();
let ports = if ports.is_empty() {
Cell::new("")
} else {
Cell::new(format!(":{}", ports.join(", :")))
};
let image = self.1.image.as_deref().unwrap_or("Unknown");
let mut res = vec![
Cell::new(self.1.name.clone()).add_attribute(Attribute::Bold),
Cell::new(self.1.state.to_string())
.fg(color)
.add_attribute(Attribute::Bold),
Cell::new(self.0.unwrap_or("Unknown")),
ports,
Cell::new(networks.join(", ")),
Cell::new(clamp_sha(image)),
];
if !links {
return res;
}
let link = if let Some(server_id) = self.1.server_id {
format!(
"{}/servers/{server_id}/container/{}",
cli_config().host,
self.1.name
)
} else {
String::new()
};
res.push(Cell::new(link));
res
}
}

View File

@@ -0,0 +1,366 @@
use std::path::Path;
use anyhow::Context;
use colored::Colorize;
use database::mungos::mongodb::bson::{Document, doc};
use komodo_client::entities::{
config::cli::args::database::DatabaseCommand, optional_string,
};
use crate::{command::sanitize_uri, config::cli_config};
pub async fn handle(command: &DatabaseCommand) -> anyhow::Result<()> {
match command {
DatabaseCommand::Backup { yes, .. } => backup(*yes).await,
DatabaseCommand::Restore {
restore_folder,
index,
yes,
..
} => restore(restore_folder.as_deref(), *index, *yes).await,
DatabaseCommand::Prune { yes, .. } => prune(*yes).await,
DatabaseCommand::Copy { yes, index, .. } => {
copy(*index, *yes).await
}
DatabaseCommand::V1Downgrade { yes } => v1_downgrade(*yes).await,
}
}
async fn backup(yes: bool) -> anyhow::Result<()> {
let config = cli_config();
println!(
"\n🦎 {} Database {} Utility 🦎",
"Komodo".bold(),
"Backup".green().bold()
);
println!(
"\n{}\n",
" - Backup all database contents to gzip compressed files."
.dimmed()
);
if let Some(uri) = optional_string(&config.database.uri) {
println!("{}: {}", " - Source URI".dimmed(), sanitize_uri(&uri));
}
if let Some(address) = optional_string(&config.database.address) {
println!("{}: {address}", " - Source Address".dimmed());
}
if let Some(username) = optional_string(&config.database.username) {
println!("{}: {username}", " - Source Username".dimmed());
}
println!(
"{}: {}\n",
" - Source Db Name".dimmed(),
config.database.db_name,
);
println!(
"{}: {:?}",
" - Backups Folder".dimmed(),
config.backups_folder
);
if config.max_backups == 0 {
println!(
"{}{}",
" - Backup pruning".dimmed(),
"disabled".red().dimmed()
);
} else {
println!("{}: {}", " - Max Backups".dimmed(), config.max_backups);
}
crate::command::wait_for_enter("start backup", yes)?;
let db = database::init(&config.database).await?;
database::utils::backup(&db, &config.backups_folder).await?;
// Early return if backup pruning disabled
if config.max_backups == 0 {
return Ok(());
}
// Know that new backup was taken successfully at this point,
// safe to prune old backup folders
prune_inner().await
}
async fn restore(
restore_folder: Option<&Path>,
index: bool,
yes: bool,
) -> anyhow::Result<()> {
let config = cli_config();
println!(
"\n🦎 {} Database {} Utility 🦎",
"Komodo".bold(),
"Restore".purple().bold()
);
println!(
"\n{}\n",
" - Restores database contents from gzip compressed files."
.dimmed()
);
if let Some(uri) = optional_string(&config.database_target.uri) {
println!("{}: {}", " - Target URI".dimmed(), sanitize_uri(&uri));
}
if let Some(address) =
optional_string(&config.database_target.address)
{
println!("{}: {address}", " - Target Address".dimmed());
}
if let Some(username) =
optional_string(&config.database_target.username)
{
println!("{}: {username}", " - Target Username".dimmed());
}
println!(
"{}: {}",
" - Target Db Name".dimmed(),
config.database_target.db_name,
);
if !index {
println!(
"{}: {}",
" - Target Db Indexing".dimmed(),
"DISABLED".red(),
);
}
println!(
"\n{}: {:?}",
" - Backups Folder".dimmed(),
config.backups_folder
);
if let Some(restore_folder) = restore_folder {
println!("{}: {restore_folder:?}", " - Restore Folder".dimmed());
}
crate::command::wait_for_enter("start restore", yes)?;
let db = if index {
database::Client::new(&config.database_target).await?.db
} else {
database::init(&config.database_target).await?
};
database::utils::restore(
&db,
&config.backups_folder,
restore_folder,
)
.await
}
async fn prune(yes: bool) -> anyhow::Result<()> {
let config = cli_config();
println!(
"\n🦎 {} Database {} Utility 🦎",
"Komodo".bold(),
"Backup Prune".cyan().bold()
);
println!(
"\n{}\n",
" - Prunes database backup folders when greater than the configured amount."
.dimmed()
);
println!(
"{}: {:?}",
" - Backups Folder".dimmed(),
config.backups_folder
);
if config.max_backups == 0 {
println!(
"{}{}",
" - Backup pruning".dimmed(),
"disabled".red().dimmed()
);
} else {
println!("{}: {}", " - Max Backups".dimmed(), config.max_backups);
}
// Early return if backup pruning disabled
if config.max_backups == 0 {
info!(
"Backup pruning is disabled, enabled using 'max_backups' (KOMODO_CLI_MAX_BACKUPS)"
);
return Ok(());
}
crate::command::wait_for_enter("start backup prune", yes)?;
prune_inner().await
}
async fn prune_inner() -> anyhow::Result<()> {
let config = cli_config();
let mut backups_dir =
match tokio::fs::read_dir(&config.backups_folder)
.await
.context("Failed to read backups folder for prune")
{
Ok(backups_dir) => backups_dir,
Err(e) => {
warn!("{e:#}");
return Ok(());
}
};
let mut backup_folders = Vec::new();
loop {
match backups_dir.next_entry().await {
Ok(Some(entry)) => {
let Ok(metadata) = entry.metadata().await else {
continue;
};
if metadata.is_dir() {
backup_folders.push(entry.path());
}
}
Ok(None) => break,
Err(_) => {
continue;
}
}
}
// Ordered from oldest -> newest
backup_folders.sort();
let max_backups = config.max_backups as usize;
let backup_folders_len = backup_folders.len();
// Early return if under the backup count threshold
if backup_folders_len <= max_backups {
info!("No backups to prune");
return Ok(());
}
let to_delete =
&backup_folders[..(backup_folders_len - max_backups)];
info!("Pruning old backups: {to_delete:?}");
for path in to_delete {
if let Err(e) =
tokio::fs::remove_dir_all(path).await.with_context(|| {
format!("Failed to delete backup folder at {path:?}")
})
{
warn!("{e:#}");
}
}
Ok(())
}
async fn copy(index: bool, yes: bool) -> anyhow::Result<()> {
let config = cli_config();
println!(
"\n🦎 {} Database {} Utility 🦎",
"Komodo".bold(),
"Copy".blue().bold()
);
println!(
"\n{}\n",
" - Copies database contents to another database.".dimmed()
);
if let Some(uri) = optional_string(&config.database.uri) {
println!("{}: {}", " - Source URI".dimmed(), sanitize_uri(&uri));
}
if let Some(address) = optional_string(&config.database.address) {
println!("{}: {address}", " - Source Address".dimmed());
}
if let Some(username) = optional_string(&config.database.username) {
println!("{}: {username}", " - Source Username".dimmed());
}
println!(
"{}: {}\n",
" - Source Db Name".dimmed(),
config.database.db_name,
);
if let Some(uri) = optional_string(&config.database_target.uri) {
println!("{}: {}", " - Target URI".dimmed(), sanitize_uri(&uri));
}
if let Some(address) =
optional_string(&config.database_target.address)
{
println!("{}: {address}", " - Target Address".dimmed());
}
if let Some(username) =
optional_string(&config.database_target.username)
{
println!("{}: {username}", " - Target Username".dimmed());
}
println!(
"{}: {}",
" - Target Db Name".dimmed(),
config.database_target.db_name,
);
if !index {
println!(
"{}: {}",
" - Target Db Indexing".dimmed(),
"DISABLED".red(),
);
}
crate::command::wait_for_enter("start copy", yes)?;
let source_db = database::init(&config.database).await?;
let target_db = if index {
database::Client::new(&config.database_target).await?.db
} else {
database::init(&config.database_target).await?
};
database::utils::copy(&source_db, &target_db).await
}
async fn v1_downgrade(yes: bool) -> anyhow::Result<()> {
let config = cli_config();
println!(
"\n🦎 {} Database {} 🦎",
"Komodo".bold(),
"V1 Downgrade".purple().bold()
);
println!(
"\n{}\n",
" - Downgrade the database to V1 compatible data structures."
.dimmed()
);
if let Some(uri) = optional_string(&config.database.uri) {
println!("{}: {}", " - URI".dimmed(), sanitize_uri(&uri));
}
if let Some(address) = optional_string(&config.database.address) {
println!("{}: {address}", " - Address".dimmed());
}
if let Some(username) = optional_string(&config.database.username) {
println!("{}: {username}", " - Username".dimmed());
}
println!(
"{}: {}\n",
" - Db Name".dimmed(),
config.database.db_name,
);
crate::command::wait_for_enter("run downgrade", yes)?;
let db = database::init(&config.database).await?;
db.collection::<Document>("Server")
.update_many(doc! {}, doc! { "$set": { "info": null } })
.await
.context("Failed to downgrade Server schema")?;
info!(
"V1 Downgrade complete. Ready to downgrade to komodo-core:1 ✅"
);
Ok(())
}

View File

@@ -0,0 +1,586 @@
use std::time::Duration;
use colored::Colorize;
use futures_util::{StreamExt, stream::FuturesUnordered};
use komodo_client::{
api::execute::{
BatchExecutionResponse, BatchExecutionResponseItem, Execution,
},
entities::{resource_link, update::Update},
};
use crate::config::cli_config;
enum ExecutionResult {
Single(Box<Update>),
Batch(BatchExecutionResponse),
}
pub async fn handle(
execution: &Execution,
yes: bool,
) -> anyhow::Result<()> {
if matches!(execution, Execution::None(_)) {
println!("Got 'none' execution. Doing nothing...");
tokio::time::sleep(Duration::from_secs(3)).await;
println!("Finished doing nothing. Exiting...");
std::process::exit(0);
}
println!("\n{}: Execution", "Mode".dimmed());
match execution {
Execution::None(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunAction(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchRunAction(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunProcedure(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchRunProcedure(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchRunBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CancelBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::Deploy(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchDeploy(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PullDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RestartDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PauseDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::UnpauseDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DestroyDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchDestroyDeployment(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CloneRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchCloneRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PullRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchPullRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BuildRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchBuildRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CancelRepoBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RestartContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PauseContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::UnpauseContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DestroyContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartAllContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RestartAllContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PauseAllContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::UnpauseAllContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopAllContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DeleteNetwork(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneNetworks(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DeleteImage(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneImages(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DeleteVolume(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneVolumes(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneDockerBuilders(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneBuildx(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneSystem(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunSync(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CommitSync(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DeployStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchDeployStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DeployStackIfChanged(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchDeployStackIfChanged(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PullStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchPullStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RestartStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PauseStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::UnpauseStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DestroyStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BatchDestroyStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunStackService(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::TestAlerter(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::SendAlert(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::ClearRepoCache(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BackupCoreDatabase(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::GlobalAutoUpdate(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RotateAllServerKeys(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RotateCoreKeys(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::Sleep(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
}
super::wait_for_enter("run execution", yes)?;
info!("Running Execution...");
let client = super::komodo_client().await?;
let res = match execution.clone() {
Execution::RunAction(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchRunAction(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::RunProcedure(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchRunProcedure(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::RunBuild(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchRunBuild(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::CancelBuild(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::Deploy(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchDeploy(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::PullDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StartDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RestartDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PauseDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::UnpauseDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StopDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DestroyDeployment(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchDestroyDeployment(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::CloneRepo(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchCloneRepo(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::PullRepo(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchPullRepo(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::BuildRepo(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchBuildRepo(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::CancelRepoBuild(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StartContainer(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RestartContainer(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PauseContainer(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::UnpauseContainer(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StopContainer(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DestroyContainer(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StartAllContainers(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RestartAllContainers(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PauseAllContainers(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::UnpauseAllContainers(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StopAllContainers(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneContainers(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DeleteNetwork(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneNetworks(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DeleteImage(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneImages(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DeleteVolume(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneVolumes(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneDockerBuilders(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneBuildx(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PruneSystem(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RunSync(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::CommitSync(request) => client
.write(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DeployStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchDeployStack(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::DeployStackIfChanged(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchDeployStackIfChanged(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::PullStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchPullStack(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::StartStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RestartStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::PauseStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::UnpauseStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::StopStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::DestroyStack(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BatchDestroyStack(request) => {
client.execute(request).await.map(ExecutionResult::Batch)
}
Execution::RunStackService(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::TestAlerter(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::SendAlert(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::ClearRepoCache(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::BackupCoreDatabase(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::GlobalAutoUpdate(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RotateAllServerKeys(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::RotateCoreKeys(request) => client
.execute(request)
.await
.map(|u| ExecutionResult::Single(u.into())),
Execution::Sleep(request) => {
let duration =
Duration::from_millis(request.duration_ms as u64);
tokio::time::sleep(duration).await;
println!("Finished sleeping!");
std::process::exit(0)
}
Execution::None(_) => unreachable!(),
};
match res {
Ok(ExecutionResult::Single(update)) => {
poll_update_until_complete(&update).await
}
Ok(ExecutionResult::Batch(updates)) => {
let mut handles = updates
.iter()
.map(|update| async move {
match update {
BatchExecutionResponseItem::Ok(update) => {
poll_update_until_complete(update).await
}
BatchExecutionResponseItem::Err(e) => {
error!("{e:#?}");
Ok(())
}
}
})
.collect::<FuturesUnordered<_>>();
while let Some(res) = handles.next().await {
match res {
Ok(()) => {}
Err(e) => {
error!("{e:#?}");
}
}
}
Ok(())
}
Err(e) => {
error!("{e:#?}");
Ok(())
}
}
}
async fn poll_update_until_complete(
update: &Update,
) -> anyhow::Result<()> {
let link = if update.id.is_empty() {
let (resource_type, id) = update.target.extract_variant_id();
resource_link(&cli_config().host, resource_type, id)
} else {
format!("{}/updates/{}", cli_config().host, update.id)
};
println!("Link: '{}'", link.bold());
let client = super::komodo_client().await?;
let timer = tokio::time::Instant::now();
let update = client.poll_update_until_complete(&update.id).await?;
if update.success {
println!(
"FINISHED in {}: {}",
format!("{:.1?}", timer.elapsed()).bold(),
"EXECUTION SUCCESSFUL".green(),
);
} else {
eprintln!(
"FINISHED in {}: {}",
format!("{:.1?}", timer.elapsed()).bold(),
"EXECUTION FAILED".red(),
);
}
Ok(())
}

1236
bin/cli/src/command/list.rs Normal file

File diff suppressed because it is too large Load Diff

182
bin/cli/src/command/mod.rs Normal file
View File

@@ -0,0 +1,182 @@
use std::io::Read;
use anyhow::{Context, anyhow};
use chrono::TimeZone;
use colored::Colorize;
use comfy_table::{Attribute, Cell, Table};
use komodo_client::{
KomodoClient,
entities::config::cli::{CliTableBorders, args::CliFormat},
};
use serde::Serialize;
use tokio::sync::OnceCell;
use wildcard::Wildcard;
use crate::config::cli_config;
pub mod container;
pub mod database;
pub mod execute;
pub mod list;
pub mod ssh;
pub mod update;
async fn komodo_client() -> anyhow::Result<&'static KomodoClient> {
static KOMODO_CLIENT: OnceCell<KomodoClient> =
OnceCell::const_new();
KOMODO_CLIENT
.get_or_try_init(|| async {
let config = cli_config();
let (Some(key), Some(secret)) =
(&config.cli_key, &config.cli_secret)
else {
return Err(anyhow!(
"Must provide both cli_key and cli_secret"
));
};
KomodoClient::new(&config.host, key, secret)
.with_healthcheck()
.await
})
.await
}
fn wait_for_enter(
press_enter_to: &str,
skip: bool,
) -> anyhow::Result<()> {
if skip {
println!();
return Ok(());
}
println!(
"\nPress {} to {}\n",
"ENTER".green(),
press_enter_to.bold()
);
let buffer = &mut [0u8];
std::io::stdin()
.read_exact(buffer)
.context("failed to read ENTER")?;
Ok(())
}
/// Sanitizes uris of the form:
/// `protocol://username:password@address`
fn sanitize_uri(uri: &str) -> String {
// protocol: `mongodb`
// credentials_address: `username:password@address`
let Some((protocol, credentials_address)) = uri.split_once("://")
else {
// If no protocol, return as-is
return uri.to_string();
};
// credentials: `username:password`
let Some((credentials, address)) =
credentials_address.split_once('@')
else {
// If no credentials, return as-is
return uri.to_string();
};
match credentials.split_once(':') {
Some((username, _)) => {
format!("{protocol}://{username}:*****@{address}")
}
None => {
format!("{protocol}://*****@{address}")
}
}
}
fn print_items<T: PrintTable + Serialize>(
items: Vec<T>,
format: CliFormat,
links: bool,
) -> anyhow::Result<()> {
match format {
CliFormat::Table => {
let mut table = Table::new();
let preset = {
use comfy_table::presets::*;
match cli_config().table_borders {
None | Some(CliTableBorders::Horizontal) => {
UTF8_HORIZONTAL_ONLY
}
Some(CliTableBorders::Vertical) => UTF8_FULL_CONDENSED,
Some(CliTableBorders::Inside) => UTF8_NO_BORDERS,
Some(CliTableBorders::Outside) => UTF8_BORDERS_ONLY,
Some(CliTableBorders::All) => UTF8_FULL,
}
};
table.load_preset(preset).set_header(
T::header(links)
.iter()
.map(|h| Cell::new(h).add_attribute(Attribute::Bold)),
);
for item in items {
table.add_row(item.row(links));
}
println!("{table}");
}
CliFormat::Json => {
println!(
"{}",
serde_json::to_string_pretty(&items)
.context("Failed to serialize items to JSON")?
);
}
}
Ok(())
}
trait PrintTable {
fn header(links: bool) -> &'static [&'static str];
fn row(self, links: bool) -> Vec<Cell>;
}
fn parse_wildcards(items: &[String]) -> Vec<Wildcard<'_>> {
items
.iter()
.flat_map(|i| {
Wildcard::new(i.as_bytes()).inspect_err(|e| {
warn!("Failed to parse wildcard: {i} | {e:?}")
})
})
.collect::<Vec<_>>()
}
fn matches_wildcards(
wildcards: &[Wildcard<'_>],
items: &[&str],
) -> bool {
if wildcards.is_empty() {
return true;
}
items.iter().any(|item| {
wildcards.iter().any(|wc| wc.is_match(item.as_bytes()))
})
}
fn format_timetamp(ts: i64) -> anyhow::Result<String> {
let ts = chrono::Local
.timestamp_millis_opt(ts)
.single()
.context("Invalid ts")?
.format("%m/%d %H:%M:%S")
.to_string();
Ok(ts)
}
fn clamp_sha(maybe_sha: &str) -> String {
if maybe_sha.starts_with("sha256:") {
maybe_sha[0..20].to_string() + "..."
} else {
maybe_sha.to_string()
}
}
// fn text_link(link: &str, text: &str) -> String {
// format!("\x1b]8;;{link}\x07{text}\x1b]8;;\x07")
// }

193
bin/cli/src/command/ssh.rs Normal file
View File

@@ -0,0 +1,193 @@
use anyhow::Context;
use bytes::Bytes;
use colored::Colorize;
use futures_util::{SinkExt, StreamExt};
use komodo_client::{
api::write::{CreateTerminal, TerminalRecreateMode},
entities::config::cli::args::ssh::Ssh,
};
use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _};
use tokio_tungstenite::tungstenite;
pub async fn handle(
Ssh {
server,
name,
command,
recreate,
}: &Ssh,
) -> anyhow::Result<()> {
// Need to forward multiple sources into ws write
let (write_tx, mut write_rx) =
tokio::sync::mpsc::channel::<Bytes>(1024);
// ================
// SETUP RESIZING
// ================
// Subscribe to SIGWINCH for resize messages
let mut sigwinch = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::window_change(),
)
.context("failed to register SIGWINCH handler")?;
// Send first resize messsage, bailing if it fails to get the size.
write_tx.send(resize_message()?).await?;
let forward_resize = async {
while sigwinch.recv().await.is_some() {
if let Ok(resize_message) = resize_message()
&& write_tx.send(resize_message).await.is_err()
{
break;
}
}
};
let forward_stdin = async {
let mut stdin = tokio::io::stdin();
let mut buf = [0u8; 8192];
loop {
// Read into buffer starting from index 1,
// leaving first byte to represent 'data' message.
let n = match stdin.read(&mut buf[1..]).await {
Ok(0) => break, // EOF
Ok(n) => n,
Err(_) => break,
};
// Check for disconnect sequence (alt + q)
if buf[1..(n + 1)] == [197, 147] {
break;
}
let bytes = Bytes::copy_from_slice(&buf[..(n + 1)]);
if write_tx.send(bytes).await.is_err() {
break;
};
}
};
// =====================
// CONNECT AND FORWARD
// =====================
let client = super::komodo_client().await?;
// Init the terminal if it doesn't exist already.
client
.write(CreateTerminal {
server: server.to_string(),
name: name.to_string(),
command: command.clone(),
recreate: if *recreate {
TerminalRecreateMode::Always
} else {
TerminalRecreateMode::DifferentCommand
},
})
.await?;
let (mut ws_write, mut ws_read) = client
.connect_terminal_websocket(server, name)
.await?
.split();
let forward_write = async {
while let Some(bytes) = write_rx.recv().await {
if let Err(e) =
ws_write.send(tungstenite::Message::Binary(bytes)).await
{
return Some(e);
};
}
None
};
let forward_read = async {
let mut stdout = tokio::io::stdout();
loop {
match ws_read.next().await {
Some(Ok(tungstenite::Message::Binary(bytes))) => {
if let Err(e) =
tokio::io::copy(&mut bytes.as_ref(), &mut stdout)
.await
.context("Failed to copy bytes to stdout")
{
return Some(e);
}
let _ = stdout.flush().await;
}
Some(Ok(tungstenite::Message::Text(text))) => {
if let Err(e) =
tokio::io::copy(&mut text.as_ref(), &mut stdout)
.await
.context("Failed to copy text to stdout")
{
return Some(e);
}
let _ = stdout.flush().await;
}
Some(Ok(tungstenite::Message::Close(_))) => break,
Some(Err(e)) => {
return Some(
anyhow::Error::from(e).context("Websocket read error"),
);
}
None => break,
_ => {}
}
}
None
};
let guard = RawModeGuard::enable_raw_mode()?;
tokio::select! {
_ = forward_resize => drop(guard),
_ = forward_stdin => drop(guard),
e = forward_write => {
drop(guard);
if let Some(e) = e {
eprintln!("\nFailed to forward stdin | {e:#}");
}
},
e = forward_read => {
drop(guard);
if let Some(e) = e {
eprintln!("\nFailed to forward stdout | {e:#}");
}
},
};
println!("\n\n{} {}", "connection".bold(), "closed".red().bold());
// It doesn't seem to exit by itself after the raw mode stuff.
std::process::exit(0)
}
fn resize_message() -> anyhow::Result<Bytes> {
let (cols, rows) = crossterm::terminal::size()
.context("Failed to get terminal size")?;
let bytes: Vec<u8> =
format!(r#"{{"rows":{rows},"cols":{cols}}}"#).into();
let mut msg = Vec::with_capacity(bytes.len() + 1);
msg.push(0xff); // resize prefix
msg.extend(bytes);
Ok(msg.into())
}
struct RawModeGuard;
impl RawModeGuard {
fn enable_raw_mode() -> anyhow::Result<Self> {
crossterm::terminal::enable_raw_mode()
.context("Failed to enable terminal raw mode")?;
Ok(Self)
}
}
impl Drop for RawModeGuard {
fn drop(&mut self) {
if let Err(e) = crossterm::terminal::disable_raw_mode() {
eprintln!("Failed to disable terminal raw mode | {e:?}");
}
}
}

View File

@@ -0,0 +1,43 @@
use komodo_client::entities::{
build::PartialBuildConfig,
config::cli::args::update::UpdateCommand,
deployment::PartialDeploymentConfig, repo::PartialRepoConfig,
server::PartialServerConfig, stack::PartialStackConfig,
sync::PartialResourceSyncConfig,
};
mod resource;
mod user;
mod variable;
pub async fn handle(command: &UpdateCommand) -> anyhow::Result<()> {
match command {
UpdateCommand::Build(update) => {
resource::update::<PartialBuildConfig>(update).await
}
UpdateCommand::Deployment(update) => {
resource::update::<PartialDeploymentConfig>(update).await
}
UpdateCommand::Repo(update) => {
resource::update::<PartialRepoConfig>(update).await
}
UpdateCommand::Server(update) => {
resource::update::<PartialServerConfig>(update).await
}
UpdateCommand::Stack(update) => {
resource::update::<PartialStackConfig>(update).await
}
UpdateCommand::Sync(update) => {
resource::update::<PartialResourceSyncConfig>(update).await
}
UpdateCommand::Variable {
name,
value,
secret,
yes,
} => variable::update(name, value, *secret, *yes).await,
UpdateCommand::User { username, command } => {
user::update(username, command).await
}
}
}

View File

@@ -0,0 +1,152 @@
use anyhow::Context;
use colored::Colorize;
use komodo_client::{
api::write::{
UpdateBuild, UpdateDeployment, UpdateRepo, UpdateResourceSync,
UpdateServer, UpdateStack,
},
entities::{
build::PartialBuildConfig,
config::cli::args::update::UpdateResource,
deployment::PartialDeploymentConfig, repo::PartialRepoConfig,
server::PartialServerConfig, stack::PartialStackConfig,
sync::PartialResourceSyncConfig,
},
};
use serde::{Serialize, de::DeserializeOwned};
pub async fn update<
T: std::fmt::Debug + Serialize + DeserializeOwned + ResourceUpdate,
>(
UpdateResource {
resource,
update,
yes,
}: &UpdateResource,
) -> anyhow::Result<()> {
println!("\n{}: Update {}\n", "Mode".dimmed(), T::resource_type());
println!(" - {}: {resource}", "Name".dimmed());
let config = serde_qs::from_str::<T>(update)
.context("Failed to deserialize config")?;
match serde_json::to_string_pretty(&config) {
Ok(config) => {
println!(" - {}: {config}", "Update".dimmed());
}
Err(_) => {
println!(" - {}: {config:#?}", "Update".dimmed());
}
}
crate::command::wait_for_enter("update resource", *yes)?;
config.apply(resource).await
}
pub trait ResourceUpdate {
fn resource_type() -> &'static str;
async fn apply(self, resource: &str) -> anyhow::Result<()>;
}
impl ResourceUpdate for PartialBuildConfig {
fn resource_type() -> &'static str {
"Build"
}
async fn apply(self, resource: &str) -> anyhow::Result<()> {
let client = crate::command::komodo_client().await?;
client
.write(UpdateBuild {
id: resource.to_string(),
config: self,
})
.await
.context("Failed to update build config")?;
Ok(())
}
}
impl ResourceUpdate for PartialDeploymentConfig {
fn resource_type() -> &'static str {
"Deployment"
}
async fn apply(self, resource: &str) -> anyhow::Result<()> {
let client = crate::command::komodo_client().await?;
client
.write(UpdateDeployment {
id: resource.to_string(),
config: self,
})
.await
.context("Failed to update deployment config")?;
Ok(())
}
}
impl ResourceUpdate for PartialRepoConfig {
fn resource_type() -> &'static str {
"Repo"
}
async fn apply(self, resource: &str) -> anyhow::Result<()> {
let client = crate::command::komodo_client().await?;
client
.write(UpdateRepo {
id: resource.to_string(),
config: self,
})
.await
.context("Failed to update repo config")?;
Ok(())
}
}
impl ResourceUpdate for PartialServerConfig {
fn resource_type() -> &'static str {
"Server"
}
async fn apply(self, resource: &str) -> anyhow::Result<()> {
let client = crate::command::komodo_client().await?;
client
.write(UpdateServer {
id: resource.to_string(),
config: self,
})
.await
.context("Failed to update server config")?;
Ok(())
}
}
impl ResourceUpdate for PartialStackConfig {
fn resource_type() -> &'static str {
"Stack"
}
async fn apply(self, resource: &str) -> anyhow::Result<()> {
let client = crate::command::komodo_client().await?;
client
.write(UpdateStack {
id: resource.to_string(),
config: self,
})
.await
.context("Failed to update stack config")?;
Ok(())
}
}
impl ResourceUpdate for PartialResourceSyncConfig {
fn resource_type() -> &'static str {
"Sync"
}
async fn apply(self, resource: &str) -> anyhow::Result<()> {
let client = crate::command::komodo_client().await?;
client
.write(UpdateResourceSync {
id: resource.to_string(),
config: self,
})
.await
.context("Failed to update sync config")?;
Ok(())
}
}

View File

@@ -0,0 +1,122 @@
use anyhow::Context;
use colored::Colorize;
use database::mungos::mongodb::bson::doc;
use komodo_client::entities::{
config::{
cli::args::{CliEnabled, update::UpdateUserCommand},
empty_or_redacted,
},
optional_string,
};
use crate::{command::sanitize_uri, config::cli_config};
pub async fn update(
username: &str,
command: &UpdateUserCommand,
) -> anyhow::Result<()> {
match command {
UpdateUserCommand::Password {
password,
unsanitized,
yes,
} => {
update_password(username, password, *unsanitized, *yes).await
}
UpdateUserCommand::SuperAdmin { enabled, yes } => {
update_super_admin(username, *enabled, *yes).await
}
}
}
async fn update_password(
username: &str,
password: &str,
unsanitized: bool,
yes: bool,
) -> anyhow::Result<()> {
println!("\n{}: Update Password\n", "Mode".dimmed());
println!(" - {}: {username}", "Username".dimmed());
if unsanitized {
println!(" - {}: {password}", "Password".dimmed());
} else {
println!(
" - {}: {}",
"Password".dimmed(),
empty_or_redacted(password)
);
}
crate::command::wait_for_enter("update password", yes)?;
info!("Updating password...");
let db = database::Client::new(&cli_config().database).await?;
let user = db
.users
.find_one(doc! { "username": username })
.await
.context("Failed to query database for user")?
.context("No user found with given username")?;
db.set_user_password(&user, password).await?;
info!("Password updated ✅");
Ok(())
}
async fn update_super_admin(
username: &str,
super_admin: CliEnabled,
yes: bool,
) -> anyhow::Result<()> {
let config = cli_config();
println!("\n{}: Update Super Admin\n", "Mode".dimmed());
println!(" - {}: {username}", "Username".dimmed());
println!(" - {}: {super_admin}\n", "Super Admin".dimmed());
if let Some(uri) = optional_string(&config.database.uri) {
println!("{}: {}", " - Source URI".dimmed(), sanitize_uri(&uri));
}
if let Some(address) = optional_string(&config.database.address) {
println!("{}: {address}", " - Source Address".dimmed());
}
if let Some(username) = optional_string(&config.database.username) {
println!("{}: {username}", " - Source Username".dimmed());
}
println!(
"{}: {}",
" - Source Db Name".dimmed(),
config.database.db_name,
);
crate::command::wait_for_enter("update super admin", yes)?;
info!("Updating super admin...");
let db = database::Client::new(&config.database).await?;
// Make sure the user exists first before saying it is successful.
let user = db
.users
.find_one(doc! { "username": username })
.await
.context("Failed to query database for user")?
.context("No user found with given username")?;
let super_admin: bool = super_admin.into();
db.users
.update_one(
doc! { "username": user.username },
doc! { "$set": { "super_admin": super_admin } },
)
.await
.context("Failed to update user super admin on db")?;
info!("Super admin updated ✅");
Ok(())
}

View File

@@ -0,0 +1,70 @@
use anyhow::Context;
use colored::Colorize;
use komodo_client::api::{
read::GetVariable,
write::{
CreateVariable, UpdateVariableIsSecret, UpdateVariableValue,
},
};
pub async fn update(
name: &str,
value: &str,
secret: Option<bool>,
yes: bool,
) -> anyhow::Result<()> {
println!("\n{}: Update Variable\n", "Mode".dimmed());
println!(" - {}: {name}", "Name".dimmed());
println!(" - {}: {value}", "Value".dimmed());
if let Some(secret) = secret {
println!(" - {}: {secret}", "Is Secret".dimmed());
}
crate::command::wait_for_enter("update variable", yes)?;
let client = crate::command::komodo_client().await?;
let Ok(existing) = client
.read(GetVariable {
name: name.to_string(),
})
.await
else {
// Create the variable
client
.write(CreateVariable {
name: name.to_string(),
value: value.to_string(),
is_secret: secret.unwrap_or_default(),
description: Default::default(),
})
.await
.context("Failed to create variable")?;
info!("Variable created ✅");
return Ok(());
};
client
.write(UpdateVariableValue {
name: name.to_string(),
value: value.to_string(),
})
.await
.context("Failed to update variable 'value'")?;
info!("Variable 'value' updated ✅");
let Some(secret) = secret else { return Ok(()) };
if secret != existing.is_secret {
client
.write(UpdateVariableIsSecret {
name: name.to_string(),
is_secret: secret,
})
.await
.context("Failed to update variable 'is_secret'")?;
info!("Variable 'is_secret' updated to {secret} ✅");
}
Ok(())
}

280
bin/cli/src/config.rs Normal file
View File

@@ -0,0 +1,280 @@
use std::{path::PathBuf, sync::OnceLock};
use anyhow::Context;
use clap::Parser;
use colored::Colorize;
use environment_file::maybe_read_item_from_file;
use komodo_client::entities::{
config::{
DatabaseConfig,
cli::{
CliConfig, Env,
args::{CliArgs, Command, Execute, database::DatabaseCommand},
},
},
logger::LogConfig,
};
pub fn cli_args() -> &'static CliArgs {
static CLI_ARGS: OnceLock<CliArgs> = OnceLock::new();
CLI_ARGS.get_or_init(CliArgs::parse)
}
pub fn cli_env() -> &'static Env {
static CLI_ARGS: OnceLock<Env> = OnceLock::new();
CLI_ARGS.get_or_init(|| {
match envy::from_env()
.context("Failed to parse Komodo CLI environment")
{
Ok(env) => env,
Err(e) => {
panic!("{e:?}")
}
}
})
}
pub fn cli_config() -> &'static CliConfig {
static CLI_CONFIG: OnceLock<CliConfig> = OnceLock::new();
CLI_CONFIG.get_or_init(|| {
let args = cli_args();
let env = cli_env().clone();
let config_paths = args
.config_path
.clone()
.unwrap_or(env.komodo_cli_config_paths);
let debug_startup =
args.debug_startup.unwrap_or(env.komodo_cli_debug_startup);
if debug_startup {
println!(
"{}: Komodo CLI version: {}",
"DEBUG".cyan(),
env!("CARGO_PKG_VERSION").blue().bold()
);
println!(
"{}: {}: {config_paths:?}",
"DEBUG".cyan(),
"Config Paths".dimmed(),
);
}
let config_keywords = args
.config_keyword
.clone()
.unwrap_or(env.komodo_cli_config_keywords);
let config_keywords = config_keywords
.iter()
.map(String::as_str)
.collect::<Vec<_>>();
if debug_startup {
println!(
"{}: {}: {config_keywords:?}",
"DEBUG".cyan(),
"Config File Keywords".dimmed(),
);
}
let mut unparsed_config = (config::ConfigLoader {
paths: &config_paths
.iter()
.map(PathBuf::as_path)
.collect::<Vec<_>>(),
match_wildcards: &config_keywords,
include_file_name: ".kminclude",
merge_nested: env.komodo_cli_merge_nested_config,
extend_array: env.komodo_cli_extend_config_arrays,
debug_print: debug_startup,
})
.load::<serde_json::Map<String, serde_json::Value>>()
.expect("failed at parsing config from paths");
let init_parsed_config = serde_json::from_value::<CliConfig>(
serde_json::Value::Object(unparsed_config.clone()),
)
.context("Failed to parse config")
.unwrap();
let (host, key, secret) = match &args.command {
Command::Execute(Execute {
host, key, secret, ..
}) => (host.clone(), key.clone(), secret.clone()),
_ => (None, None, None),
};
let backups_folder = match &args.command {
Command::Database {
command: DatabaseCommand::Backup { backups_folder, .. },
} => backups_folder.clone(),
Command::Database {
command: DatabaseCommand::Restore { backups_folder, .. },
} => backups_folder.clone(),
_ => None,
};
let (uri, address, username, password, db_name) =
match &args.command {
Command::Database {
command:
DatabaseCommand::Copy {
uri,
address,
username,
password,
db_name,
..
},
} => (
uri.clone(),
address.clone(),
username.clone(),
password.clone(),
db_name.clone(),
),
_ => (None, None, None, None, None),
};
let profile = args
.profile
.as_ref()
.or(init_parsed_config.default_profile.as_ref());
let unparsed_config = if let Some(profile) = profile
&& !profile.is_empty()
{
// Find the profile config,
// then merge it with the Default config.
let serde_json::Value::Array(profiles) = unparsed_config
.remove("profile")
.context("Config has no profiles, but a profile is required")
.unwrap()
else {
panic!("`config.profile` is not array");
};
let Some(profile_config) = profiles.into_iter().find(|p| {
let Ok(parsed) =
serde_json::from_value::<CliConfig>(p.clone())
else {
return false;
};
&parsed.config_profile == profile
|| parsed
.config_aliases
.iter()
.any(|alias| alias == profile)
}) else {
panic!("No profile matching '{profile}' was found.");
};
let serde_json::Value::Object(profile_config) = profile_config
else {
panic!("Profile config is not Object type.");
};
config::merge_config(
unparsed_config,
profile_config.clone(),
env.komodo_cli_merge_nested_config,
env.komodo_cli_extend_config_arrays,
)
.unwrap_or(profile_config)
} else {
unparsed_config
};
let config = serde_json::from_value::<CliConfig>(
serde_json::Value::Object(unparsed_config),
)
.context("Failed to parse final config")
.unwrap();
let config_profile = if config.config_profile.is_empty() {
String::from("None")
} else {
config.config_profile
};
CliConfig {
config_profile,
config_aliases: config.config_aliases,
default_profile: config.default_profile,
table_borders: env
.komodo_cli_table_borders
.or(config.table_borders),
host: host
.or(env.komodo_cli_host)
.or(env.komodo_host)
.unwrap_or(config.host),
cli_key: key.or(env.komodo_cli_key).or(config.cli_key),
cli_secret: secret
.or(env.komodo_cli_secret)
.or(config.cli_secret),
backups_folder: backups_folder
.or(env.komodo_cli_backups_folder)
.unwrap_or(config.backups_folder),
max_backups: env
.komodo_cli_max_backups
.unwrap_or(config.max_backups),
database_target: DatabaseConfig {
uri: uri
.or(env.komodo_cli_database_target_uri)
.unwrap_or(config.database_target.uri),
address: address
.or(env.komodo_cli_database_target_address)
.unwrap_or(config.database_target.address),
username: username
.or(env.komodo_cli_database_target_username)
.unwrap_or(config.database_target.username),
password: password
.or(env.komodo_cli_database_target_password)
.unwrap_or(config.database_target.password),
db_name: db_name
.or(env.komodo_cli_database_target_db_name)
.unwrap_or(config.database_target.db_name),
app_name: config.database_target.app_name,
},
database: DatabaseConfig {
uri: maybe_read_item_from_file(
env.komodo_database_uri_file,
env.komodo_database_uri,
)
.unwrap_or(config.database.uri),
address: env
.komodo_database_address
.unwrap_or(config.database.address),
username: maybe_read_item_from_file(
env.komodo_database_username_file,
env.komodo_database_username,
)
.unwrap_or(config.database.username),
password: maybe_read_item_from_file(
env.komodo_database_password_file,
env.komodo_database_password,
)
.unwrap_or(config.database.password),
db_name: env
.komodo_database_db_name
.unwrap_or(config.database.db_name),
app_name: config.database.app_name,
},
cli_logging: LogConfig {
level: env
.komodo_cli_logging_level
.unwrap_or(config.cli_logging.level),
stdio: env
.komodo_cli_logging_stdio
.unwrap_or(config.cli_logging.stdio),
pretty: env
.komodo_cli_logging_pretty
.unwrap_or(config.cli_logging.pretty),
location: false,
ansi: env
.komodo_cli_logging_ansi
.unwrap_or(config.cli_logging.ansi),
otlp_endpoint: env
.komodo_cli_logging_otlp_endpoint
.unwrap_or(config.cli_logging.otlp_endpoint),
opentelemetry_service_name: env
.komodo_cli_logging_opentelemetry_service_name
.unwrap_or(config.cli_logging.opentelemetry_service_name),
opentelemetry_scope_name: env
.komodo_cli_logging_opentelemetry_scope_name
.unwrap_or(config.cli_logging.opentelemetry_scope_name),
},
profile: config.profile,
}
})
}

View File

@@ -1,208 +0,0 @@
use std::time::Duration;
use colored::Colorize;
use monitor_client::api::execute::Execution;
use crate::{
helpers::wait_for_enter,
state::{cli_args, monitor_client},
};
pub async fn run(execution: Execution) -> anyhow::Result<()> {
if matches!(execution, Execution::None(_)) {
println!("Got 'none' execution. Doing nothing...");
tokio::time::sleep(Duration::from_secs(3)).await;
println!("Finished doing nothing. Exiting...");
std::process::exit(0);
}
println!("\n{}: Execution", "Mode".dimmed());
match &execution {
Execution::None(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunProcedure(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CancelBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::Deploy(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RestartContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PauseContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::UnpauseContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopAllContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RemoveContainer(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CloneRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PullRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::BuildRepo(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::CancelRepoBuild(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneNetworks(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneImages(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PruneContainers(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RunSync(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DeployStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StartStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::RestartStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::PauseStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::UnpauseStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::StopStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::DestroyStack(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
Execution::Sleep(data) => {
println!("{}: {data:?}", "Data".dimmed())
}
}
if !cli_args().yes {
wait_for_enter("run execution")?;
}
info!("Running Execution...");
let res = match execution {
Execution::RunProcedure(request) => {
monitor_client().execute(request).await
}
Execution::RunBuild(request) => {
monitor_client().execute(request).await
}
Execution::CancelBuild(request) => {
monitor_client().execute(request).await
}
Execution::Deploy(request) => {
monitor_client().execute(request).await
}
Execution::StartContainer(request) => {
monitor_client().execute(request).await
}
Execution::RestartContainer(request) => {
monitor_client().execute(request).await
}
Execution::PauseContainer(request) => {
monitor_client().execute(request).await
}
Execution::UnpauseContainer(request) => {
monitor_client().execute(request).await
}
Execution::StopContainer(request) => {
monitor_client().execute(request).await
}
Execution::StopAllContainers(request) => {
monitor_client().execute(request).await
}
Execution::RemoveContainer(request) => {
monitor_client().execute(request).await
}
Execution::CloneRepo(request) => {
monitor_client().execute(request).await
}
Execution::PullRepo(request) => {
monitor_client().execute(request).await
}
Execution::BuildRepo(request) => {
monitor_client().execute(request).await
}
Execution::CancelRepoBuild(request) => {
monitor_client().execute(request).await
}
Execution::PruneNetworks(request) => {
monitor_client().execute(request).await
}
Execution::PruneImages(request) => {
monitor_client().execute(request).await
}
Execution::PruneContainers(request) => {
monitor_client().execute(request).await
}
Execution::RunSync(request) => {
monitor_client().execute(request).await
}
Execution::DeployStack(request) => {
monitor_client().execute(request).await
}
Execution::StartStack(request) => {
monitor_client().execute(request).await
}
Execution::RestartStack(request) => {
monitor_client().execute(request).await
}
Execution::PauseStack(request) => {
monitor_client().execute(request).await
}
Execution::UnpauseStack(request) => {
monitor_client().execute(request).await
}
Execution::StopStack(request) => {
monitor_client().execute(request).await
}
Execution::DestroyStack(request) => {
monitor_client().execute(request).await
}
Execution::Sleep(request) => {
let duration =
Duration::from_millis(request.duration_ms as u64);
tokio::time::sleep(duration).await;
println!("Finished sleeping!");
std::process::exit(0)
}
Execution::None(_) => unreachable!(),
};
match res {
Ok(update) => println!("\n{}: {update:#?}", "SUCCESS".green()),
Err(e) => println!("{}\n\n{e:#?}", "ERROR".red()),
}
Ok(())
}

View File

@@ -1,17 +0,0 @@
use std::io::Read;
use anyhow::Context;
use colored::Colorize;
pub fn wait_for_enter(press_enter_to: &str) -> anyhow::Result<()> {
println!(
"\nPress {} to {}\n",
"ENTER".green(),
press_enter_to.bold()
);
let buffer = &mut [0u8];
std::io::stdin()
.read_exact(buffer)
.context("failed to read ENTER")?;
Ok(())
}

View File

@@ -1,27 +1,76 @@
#[macro_use]
extern crate tracing;
use colored::Colorize;
use monitor_client::api::read::GetVersion;
use anyhow::Context;
use komodo_client::entities::config::cli::args;
mod args;
mod exec;
mod helpers;
mod state;
use crate::config::cli_config;
mod command;
mod config;
async fn app() -> anyhow::Result<()> {
dotenvy::dotenv().ok();
logger::init(&config::cli_config().cli_logging)?;
let args = config::cli_args();
let env = config::cli_env();
let debug_load =
args.debug_startup.unwrap_or(env.komodo_cli_debug_startup);
match &args.command {
args::Command::Config {
all_profiles,
unsanitized,
} => {
let mut config = if *unsanitized {
cli_config().clone()
} else {
cli_config().sanitized()
};
if !*all_profiles {
config.profile = Default::default();
}
if debug_load {
println!("\n{config:#?}");
} else {
println!(
"\nCLI Config {}",
serde_json::to_string_pretty(&config)
.context("Failed to serialize config for pretty print")?
);
}
Ok(())
}
args::Command::Container(container) => {
command::container::handle(container).await
}
args::Command::Inspect(inspect) => {
command::container::inspect_container(inspect).await
}
args::Command::List(list) => command::list::handle(list).await,
args::Command::Execute(args) => {
command::execute::handle(&args.execution, args.yes).await
}
args::Command::Update { command } => {
command::update::handle(command).await
}
args::Command::Ssh(ssh) => command::ssh::handle(ssh).await,
args::Command::Key { command } => {
noise::key::command::handle(command).await
}
args::Command::Database { command } => {
command::database::handle(command).await
}
}
}
#[tokio::main]
async fn main() -> anyhow::Result<()> {
tracing_subscriber::fmt().with_target(false).init();
let version =
state::monitor_client().read(GetVersion {}).await?.version;
info!("monitor version: {}", version.to_string().blue().bold());
match &state::cli_args().command {
args::Command::Execute { execution } => {
exec::run(execution.to_owned()).await?
}
let mut term_signal = tokio::signal::unix::signal(
tokio::signal::unix::SignalKind::terminate(),
)?;
tokio::select! {
res = tokio::spawn(app()) => res?,
_ = term_signal.recv() => Ok(()),
}
Ok(())
}

View File

@@ -1,46 +0,0 @@
use std::sync::OnceLock;
use clap::Parser;
use merge_config_files::parse_config_file;
use monitor_client::MonitorClient;
pub fn cli_args() -> &'static crate::args::CliArgs {
static CLI_ARGS: OnceLock<crate::args::CliArgs> = OnceLock::new();
CLI_ARGS.get_or_init(crate::args::CliArgs::parse)
}
pub fn monitor_client() -> &'static MonitorClient {
static MONITOR_CLIENT: OnceLock<MonitorClient> = OnceLock::new();
MONITOR_CLIENT.get_or_init(|| {
let args = cli_args();
let crate::args::CredsFile { url, key, secret } =
match (&args.url, &args.key, &args.secret) {
(Some(url), Some(key), Some(secret)) => {
crate::args::CredsFile {
url: url.clone(),
key: key.clone(),
secret: secret.clone(),
}
}
(url, key, secret) => {
let mut creds: crate::args::CredsFile =
parse_config_file(cli_args().creds.as_str())
.expect("failed to parse monitor credentials");
if let Some(url) = url {
creds.url.clone_from(url);
}
if let Some(key) = key {
creds.key.clone_from(key);
}
if let Some(secret) = secret {
creds.secret.clone_from(secret);
}
creds
}
};
futures::executor::block_on(MonitorClient::new(url, key, secret))
.expect("failed to initialize monitor client")
})
}

View File

@@ -1,5 +1,5 @@
[package]
name = "monitor_core"
name = "komodo_core"
version.workspace = true
edition.workspace = true
authors.workspace = true
@@ -15,54 +15,72 @@ path = "src/main.rs"
[dependencies]
# local
monitor_client = { workspace = true, features = ["mongo"] }
komodo_client = { workspace = true, features = ["mongo"] }
periphery_client.workspace = true
environment_file.workspace = true
interpolate.workspace = true
secret_file.workspace = true
formatting.workspace = true
transport.workspace = true
database.workspace = true
encoding.workspace = true
response.workspace = true
command.workspace = true
config.workspace = true
logger.workspace = true
cache.workspace = true
noise.workspace = true
git.workspace = true
# mogh
serror = { workspace = true, features = ["axum"] }
merge_config_files.workspace = true
async_timing_util.workspace = true
partial_derive2.workspace = true
derive_variants.workspace = true
mongo_indexed.workspace = true
resolver_api.workspace = true
toml_pretty.workspace = true
run_command.workspace = true
mungos.workspace = true
slack.workspace = true
svi.workspace = true
# external
ordered_hash_map.workspace = true
aws-credential-types.workspace = true
english-to-cron.workspace = true
openidconnect.workspace = true
jsonwebtoken.workspace = true
futures-util.workspace = true
axum-server.workspace = true
urlencoding.workspace = true
aws-sdk-ec2.workspace = true
aws-sdk-ecr.workspace = true
aws-config.workspace = true
tokio-util.workspace = true
axum-extra.workspace = true
tower-http.workspace = true
serde_json.workspace = true
serde_yaml.workspace = true
serde_yaml_ng.workspace = true
typeshare.workspace = true
octorust.workspace = true
chrono-tz.workspace = true
indexmap.workspace = true
wildcard.workspace = true
arc-swap.workspace = true
colored.workspace = true
dashmap.workspace = true
tracing.workspace = true
reqwest.workspace = true
futures.workspace = true
nom_pem.workspace = true
anyhow.workspace = true
dotenvy.workspace = true
anyhow.workspace = true
croner.workspace = true
chrono.workspace = true
bcrypt.workspace = true
base64.workspace = true
rustls.workspace = true
bytes.workspace = true
tokio.workspace = true
serde.workspace = true
strum.workspace = true
regex.workspace = true
axum.workspace = true
toml.workspace = true
uuid.workspace = true
envy.workspace = true
rand.workspace = true
hmac.workspace = true
sha2.workspace = true
jwt.workspace = true
hex.workspace = true
url.workspace = true

View File

@@ -1,39 +0,0 @@
# Build Core
FROM rust:1.80.1-bookworm AS core-builder
WORKDIR /builder
COPY . .
RUN cargo build -p monitor_core --release
# Build Frontend
FROM node:20.12-alpine AS frontend-builder
WORKDIR /builder
COPY ./frontend ./frontend
COPY ./client/core/ts ./client
RUN cd client && yarn && yarn build && yarn link
RUN cd frontend && yarn link @monitor/client && yarn && yarn build
# Final Image
FROM debian:bookworm-slim
# Install Deps
RUN apt update && apt install -y git curl unzip ca-certificates && \
curl -SL https://github.com/docker/compose/releases/download/v2.29.1/docker-compose-linux-x86_64 -o /usr/local/bin/docker-compose && \
chmod +x /usr/local/bin/docker-compose && \
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" && \
unzip awscliv2.zip && \
./aws/install
# Copy
COPY ./config_example/core.config.example.toml /config/config.toml
COPY --from=core-builder /builder/target/release/core /
COPY --from=frontend-builder /builder/frontend/dist /frontend
# Hint at the port
EXPOSE 9000
# Label for Ghcr
LABEL org.opencontainers.image.source=https://github.com/mbecker20/monitor
LABEL org.opencontainers.image.description="Monitor Core"
LABEL org.opencontainers.image.licenses=GPL-3.0
CMD ["./core"]

68
bin/core/aio.Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
## All in one, multi stage compile + runtime Docker build for your architecture.
# Build Core
FROM rust:1.90.0-bullseye AS core-builder
RUN cargo install cargo-strip
WORKDIR /builder
COPY Cargo.toml Cargo.lock ./
COPY ./lib ./lib
COPY ./client/core/rs ./client/core/rs
COPY ./client/periphery ./client/periphery
COPY ./bin/core ./bin/core
COPY ./bin/cli ./bin/cli
# Compile app
RUN cargo build -p komodo_core --release && \
cargo build -p komodo_cli --release && \
cargo strip
# Build Frontend
FROM node:20.12-alpine AS frontend-builder
WORKDIR /builder
COPY ./frontend ./frontend
COPY ./client/core/ts ./client
RUN cd client && yarn && yarn build && yarn link
RUN cd frontend && yarn link komodo_client && yarn && yarn build
# Final Image
FROM debian:bullseye-slim
COPY ./bin/core/starship.toml /starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Setup an application directory
WORKDIR /app
# Copy
COPY ./config/core.config.toml /config/.default.config.toml
COPY --from=frontend-builder /builder/frontend/dist /app/frontend
COPY --from=core-builder /builder/target/release/core /usr/local/bin/core
COPY --from=core-builder /builder/target/release/km /usr/local/bin/km
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno
# Set $DENO_DIR and preload external Deno deps
ENV DENO_DIR=/action-cache/deno
RUN mkdir /action-cache && \
cd /action-cache && \
deno install jsr:@std/yaml jsr:@std/toml
COPY ./bin/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Hint at the port
EXPOSE 9120
ENV KOMODO_CLI_CONFIG_PATHS="/config"
# This ensures any `komodo.cli.*` takes precedence over the Core `/config/*config.*`
ENV KOMODO_CLI_CONFIG_KEYWORDS="*config.*,*komodo.cli*.*"
CMD [ "/bin/bash", "-c", "update-ca-certificates && core" ]
# Label to prevent Komodo from stopping with StopAllContainers
LABEL komodo.skip="true"
# Label for Ghcr
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo Core"
LABEL org.opencontainers.image.licenses="GPL-3.0"

14
bin/core/debian-deps.sh Normal file
View File

@@ -0,0 +1,14 @@
#!/bin/bash
## Core deps installer
apt-get update
apt-get install -y git curl ca-certificates iproute2
rm -rf /var/lib/apt/lists/*
# Starship prompt
curl -sS https://starship.rs/install.sh | sh -s -- --yes --bin-dir /usr/local/bin
echo 'export STARSHIP_CONFIG=/starship.toml' >> /root/.bashrc
echo 'eval "$(starship init bash)"' >> /root/.bashrc

View File

@@ -0,0 +1,65 @@
## Assumes the latest binaries for x86_64 and aarch64 are already built (by binaries.Dockerfile).
## Sets up the necessary runtime container dependencies for Komodo Core.
## Since theres no heavy build here, QEMU multi-arch builds are fine for this image.
ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest
ARG FRONTEND_IMAGE=ghcr.io/moghtech/komodo-frontend:latest
ARG X86_64_BINARIES=${BINARIES_IMAGE}-x86_64
ARG AARCH64_BINARIES=${BINARIES_IMAGE}-aarch64
# This is required to work with COPY --from
FROM ${X86_64_BINARIES} AS x86_64
FROM ${AARCH64_BINARIES} AS aarch64
FROM ${FRONTEND_IMAGE} AS frontend
# Final Image
FROM debian:bullseye-slim
COPY ./bin/core/starship.toml /starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
WORKDIR /app
ARG TARGETPLATFORM
# Copy both binaries initially, but only keep appropriate one for the TARGETPLATFORM.
COPY --from=x86_64 /core /app/core/linux/amd64
COPY --from=aarch64 /core /app/core/linux/arm64
RUN mv /app/core/${TARGETPLATFORM} /usr/local/bin/core && rm -r /app/core
# Same for km
COPY --from=x86_64 /km /app/km/linux/amd64
COPY --from=aarch64 /km /app/km/linux/arm64
RUN mv /app/km/${TARGETPLATFORM} /usr/local/bin/km && rm -r /app/km
# Copy default config / static frontend / deno binary
COPY ./config/core.config.toml /config/.default.config.toml
COPY --from=frontend /frontend /app/frontend
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno
# Set $DENO_DIR and preload external Deno deps
ENV DENO_DIR=/action-cache/deno
RUN mkdir /action-cache && \
cd /action-cache && \
deno install jsr:@std/yaml jsr:@std/toml
COPY ./bin/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Hint at the port
EXPOSE 9120
ENV KOMODO_CLI_CONFIG_PATHS="/config"
# This ensures any `komodo.cli.*` takes precedence over the Core `/config/*config.*`
ENV KOMODO_CLI_CONFIG_KEYWORDS="*config.*,*komodo.cli*.*"
ENTRYPOINT [ "entrypoint.sh" ]
CMD [ "core" ]
# Label to prevent Komodo from stopping with StopAllContainers
LABEL komodo.skip="true"
# Label for Ghcr
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo Core"
LABEL org.opencontainers.image.licenses="GPL-3.0"

View File

@@ -0,0 +1,54 @@
## Assumes the latest binaries for the required arch are already built (by binaries.Dockerfile).
## Sets up the necessary runtime container dependencies for Komodo Core.
ARG BINARIES_IMAGE=ghcr.io/moghtech/komodo-binaries:latest
# This is required to work with COPY --from
FROM ${BINARIES_IMAGE} AS binaries
# Build Frontend
FROM node:20.12-alpine AS frontend-builder
WORKDIR /builder
COPY ./frontend ./frontend
COPY ./client/core/ts ./client
RUN cd client && yarn && yarn build && yarn link
RUN cd frontend && yarn link komodo_client && yarn && yarn build
FROM debian:bullseye-slim
COPY ./bin/core/starship.toml /starship.toml
COPY ./bin/core/debian-deps.sh .
RUN sh ./debian-deps.sh && rm ./debian-deps.sh
# Copy
COPY ./config/core.config.toml /config/.default.config.toml
COPY --from=frontend-builder /builder/frontend/dist /app/frontend
COPY --from=binaries /core /usr/local/bin/core
COPY --from=binaries /km /usr/local/bin/km
COPY --from=denoland/deno:bin /deno /usr/local/bin/deno
# Set $DENO_DIR and preload external Deno deps
ENV DENO_DIR=/action-cache/deno
RUN mkdir /action-cache && \
cd /action-cache && \
deno install jsr:@std/yaml jsr:@std/toml
COPY ./bin/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
# Hint at the port
EXPOSE 9120
ENV KOMODO_CLI_CONFIG_PATHS="/config"
# This ensures any `komodo.cli.*` takes precedence over the Core `/config/*config.*`
ENV KOMODO_CLI_CONFIG_KEYWORDS="*config.*,*komodo.cli*.*"
ENTRYPOINT [ "entrypoint.sh" ]
CMD [ "core" ]
# Label to prevent Komodo from stopping with StopAllContainers
LABEL komodo.skip="true"
# Label for Ghcr
LABEL org.opencontainers.image.source="https://github.com/moghtech/komodo"
LABEL org.opencontainers.image.description="Komodo Core"
LABEL org.opencontainers.image.licenses="GPL-3.0"

View File

@@ -0,0 +1,307 @@
use std::sync::OnceLock;
use serde::Serialize;
use super::*;
pub async fn send_alert(
url: &str,
alert: &Alert,
) -> anyhow::Result<()> {
let level = fmt_level(alert.level);
let content = match &alert.data {
AlertData::Test { id, name } => {
let link = resource_link(ResourceTargetVariant::Alerter, id);
format!(
"{level} | If you see this message, then Alerter **{name}** is **working**\n{link}"
)
}
AlertData::ServerVersionMismatch {
id,
name,
region,
server_version,
core_version,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | **{name}**{region} | Periphery version now matches Core version ✅\n{link}"
)
}
_ => {
format!(
"{level} | **{name}**{region} | Version mismatch detected ⚠️\nPeriphery: **{server_version}** | Core: **{core_version}**\n{link}"
)
}
}
}
AlertData::ServerUnreachable {
id,
name,
region,
err,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | **{name}**{region} is now **connected**\n{link}"
)
}
SeverityLevel::Critical => {
let err = err
.as_ref()
.map(|e| format!("\n**error**: {e:#?}"))
.unwrap_or_default();
format!(
"{level} | **{name}**{region} is **unreachable** ❌\n{link}{err}"
)
}
_ => unreachable!(),
}
}
AlertData::ServerCpu {
id,
name,
region,
percentage,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
format!(
"{level} | **{name}**{region} cpu usage at **{percentage:.1}%**\n{link}"
)
}
AlertData::ServerMem {
id,
name,
region,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
let percentage = 100.0 * used_gb / total_gb;
format!(
"{level} | **{name}**{region} memory usage at **{percentage:.1}%** 💾\n\nUsing **{used_gb:.1} GiB** / **{total_gb:.1} GiB**\n{link}"
)
}
AlertData::ServerDisk {
id,
name,
region,
path,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
let percentage = 100.0 * used_gb / total_gb;
format!(
"{level} | **{name}**{region} disk usage at **{percentage:.1}%** 💿\nmount point: `{path:?}`\nusing **{used_gb:.1} GiB** / **{total_gb:.1} GiB**\n{link}"
)
}
AlertData::ContainerStateChange {
id,
name,
server_id: _server_id,
server_name,
from,
to,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
let to = fmt_docker_container_state(to);
format!(
"📦 Deployment **{name}** is now **{to}**\nserver: **{server_name}**\nprevious: **{from}**\n{link}"
)
}
AlertData::DeploymentImageUpdateAvailable {
id,
name,
server_id: _server_id,
server_name,
image,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
format!(
"⬆ Deployment **{name}** has an update available\nserver: **{server_name}**\nimage: **{image}**\n{link}"
)
}
AlertData::DeploymentAutoUpdated {
id,
name,
server_id: _server_id,
server_name,
image,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
format!(
"⬆ Deployment **{name}** was updated automatically ⏫\nserver: **{server_name}**\nimage: **{image}**\n{link}"
)
}
AlertData::StackStateChange {
id,
name,
server_id: _server_id,
server_name,
from,
to,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
let to = fmt_stack_state(to);
format!(
"🥞 Stack **{name}** is now {to}\nserver: **{server_name}**\nprevious: **{from}**\n{link}"
)
}
AlertData::StackImageUpdateAvailable {
id,
name,
server_id: _server_id,
server_name,
service,
image,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
format!(
"⬆ Stack **{name}** has an update available\nserver: **{server_name}**\nservice: **{service}**\nimage: **{image}**\n{link}"
)
}
AlertData::StackAutoUpdated {
id,
name,
server_id: _server_id,
server_name,
images,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
let images_label =
if images.len() > 1 { "images" } else { "image" };
let images = images.join(", ");
format!(
"⬆ Stack **{name}** was updated automatically ⏫\nserver: **{server_name}**\n{images_label}: **{images}**\n{link}"
)
}
AlertData::AwsBuilderTerminationFailed {
instance_id,
message,
} => {
format!(
"{level} | Failed to terminated AWS builder instance\ninstance id: **{instance_id}**\n{message}"
)
}
AlertData::ResourceSyncPendingUpdates { id, name } => {
let link =
resource_link(ResourceTargetVariant::ResourceSync, id);
format!(
"{level} | Pending resource sync updates on **{name}**\n{link}"
)
}
AlertData::BuildFailed { id, name, version } => {
let link = resource_link(ResourceTargetVariant::Build, id);
format!(
"{level} | Build **{name}** failed\nversion: **v{version}**\n{link}"
)
}
AlertData::RepoBuildFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Repo, id);
format!("{level} | Repo build for **{name}** failed\n{link}")
}
AlertData::ProcedureFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Procedure, id);
format!("{level} | Procedure **{name}** failed\n{link}")
}
AlertData::ActionFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Action, id);
format!("{level} | Action **{name}** failed\n{link}")
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let link = resource_link(*resource_type, id);
format!(
"{level} | **{name}** ({resource_type}) | Scheduled run started 🕝\n{link}"
)
}
AlertData::Custom { message, details } => {
format!(
"{level} | {message}{}",
if details.is_empty() {
format_args!("")
} else {
format_args!("\n{details}")
}
)
}
AlertData::None {} => Default::default(),
};
if content.is_empty() {
return Ok(());
}
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut url_interpolated = url.to_string();
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator.interpolate_string(&mut url_interpolated)?;
send_message(&url_interpolated, &content)
.await
.map_err(|e| {
let replacers = interpolator
.secret_replacers
.into_iter()
.collect::<Vec<_>>();
let sanitized_error =
svi::replace_in_string(&format!("{e:?}"), &replacers);
anyhow::Error::msg(format!(
"Error with slack request: {sanitized_error}"
))
})
}
async fn send_message(
url: &str,
content: &str,
) -> anyhow::Result<()> {
let body = DiscordMessageBody { content };
let response = http_client()
.post(url)
.json(&body)
.send()
.await
.context("Failed to send message")?;
let status = response.status();
if status.is_success() {
Ok(())
} else {
let text = response.text().await.with_context(|| {
format!("Failed to send message to Discord | {status} | failed to get response text")
})?;
Err(anyhow::anyhow!(
"Failed to send message to Discord | {status} | {text}"
))
}
}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}
#[derive(Serialize)]
struct DiscordMessageBody<'a> {
content: &'a str,
}

477
bin/core/src/alert/mod.rs Normal file
View File

@@ -0,0 +1,477 @@
use anyhow::{Context, anyhow};
use database::mungos::{find::find_collect, mongodb::bson::doc};
use derive_variants::ExtractVariant;
use futures_util::future::join_all;
use interpolate::Interpolator;
use komodo_client::entities::{
ResourceTargetVariant,
alert::{Alert, AlertData, AlertDataVariant, SeverityLevel},
alerter::*,
deployment::DeploymentState,
komodo_timestamp,
stack::StackState,
};
use crate::helpers::query::get_variables_and_secrets;
use crate::helpers::{
maintenance::is_in_maintenance, query::VariablesAndSecrets,
};
use crate::{config::core_config, state::db_client};
mod discord;
mod ntfy;
mod pushover;
mod slack;
pub async fn send_alerts(alerts: &[Alert]) {
if alerts.is_empty() {
return;
}
let Ok(alerters) = find_collect(
&db_client().alerters,
doc! { "config.enabled": true },
None,
)
.await
.inspect_err(|e| {
error!(
"ERROR sending alerts | failed to get alerters from db | {e:#}"
)
}) else {
return;
};
let handles = alerts
.iter()
.map(|alert| send_alert_to_alerters(&alerters, alert));
join_all(handles).await;
}
async fn send_alert_to_alerters(alerters: &[Alerter], alert: &Alert) {
if alerters.is_empty() {
return;
}
let handles = alerters
.iter()
.map(|alerter| send_alert_to_alerter(alerter, alert));
join_all(handles)
.await
.into_iter()
.filter_map(|res| res.err())
.for_each(|e| error!("{e:#}"));
}
pub async fn send_alert_to_alerter(
alerter: &Alerter,
alert: &Alert,
) -> anyhow::Result<()> {
// Don't send if not enabled
if !alerter.config.enabled {
return Ok(());
}
if is_in_maintenance(
&alerter.config.maintenance_windows,
komodo_timestamp(),
) {
return Ok(());
}
let alert_type = alert.data.extract_variant();
// In the test case, we don't want the filters inside this
// block to stop the test from being sent to the alerting endpoint.
if alert_type != AlertDataVariant::Test {
// Don't send if alert type not configured on the alerter
if !alerter.config.alert_types.is_empty()
&& !alerter.config.alert_types.contains(&alert_type)
{
return Ok(());
}
// Don't send if resource is in the blacklist
if alerter.config.except_resources.contains(&alert.target) {
return Ok(());
}
// Don't send if whitelist configured and target is not included
if !alerter.config.resources.is_empty()
&& !alerter.config.resources.contains(&alert.target)
{
return Ok(());
}
}
match &alerter.config.endpoint {
AlerterEndpoint::Custom(CustomAlerterEndpoint { url }) => {
send_custom_alert(url, alert).await.with_context(|| {
format!(
"Failed to send alert to Custom Alerter {}",
alerter.name
)
})
}
AlerterEndpoint::Slack(SlackAlerterEndpoint { url }) => {
slack::send_alert(url, alert).await.with_context(|| {
format!(
"Failed to send alert to Slack Alerter {}",
alerter.name
)
})
}
AlerterEndpoint::Discord(DiscordAlerterEndpoint { url }) => {
discord::send_alert(url, alert).await.with_context(|| {
format!(
"Failed to send alert to Discord Alerter {}",
alerter.name
)
})
}
AlerterEndpoint::Ntfy(NtfyAlerterEndpoint { url, email }) => {
ntfy::send_alert(url, email.as_deref(), alert)
.await
.with_context(|| {
format!(
"Failed to send alert to ntfy Alerter {}",
alerter.name
)
})
}
AlerterEndpoint::Pushover(PushoverAlerterEndpoint { url }) => {
pushover::send_alert(url, alert).await.with_context(|| {
format!(
"Failed to send alert to Pushover Alerter {}",
alerter.name
)
})
}
}
}
async fn send_custom_alert(
url: &str,
alert: &Alert,
) -> anyhow::Result<()> {
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut url_interpolated = url.to_string();
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator.interpolate_string(&mut url_interpolated)?;
let res = reqwest::Client::new()
.post(url_interpolated)
.json(alert)
.send()
.await
.map_err(|e| {
let replacers = interpolator
.secret_replacers
.into_iter()
.collect::<Vec<_>>();
let sanitized_error =
svi::replace_in_string(&format!("{e:?}"), &replacers);
anyhow::Error::msg(format!(
"Error with request: {sanitized_error}"
))
})
.context("failed at post request to alerter")?;
let status = res.status();
if !status.is_success() {
let text = res
.text()
.await
.context("failed to get response text on alerter response")?;
return Err(anyhow!(
"post to alerter failed | {status} | {text}"
));
}
Ok(())
}
fn fmt_region(region: &Option<String>) -> String {
match region {
Some(region) => format!(" ({region})"),
None => String::new(),
}
}
fn fmt_docker_container_state(state: &DeploymentState) -> String {
match state {
DeploymentState::Running => String::from("Running ▶️"),
DeploymentState::Exited => String::from("Exited 🛑"),
DeploymentState::Restarting => String::from("Restarting 🔄"),
DeploymentState::NotDeployed => String::from("Not Deployed"),
_ => state.to_string(),
}
}
fn fmt_stack_state(state: &StackState) -> String {
match state {
StackState::Running => String::from("Running ▶️"),
StackState::Stopped => String::from("Stopped 🛑"),
StackState::Restarting => String::from("Restarting 🔄"),
StackState::Down => String::from("Down ⬇️"),
_ => state.to_string(),
}
}
fn fmt_level(level: SeverityLevel) -> &'static str {
match level {
SeverityLevel::Critical => "CRITICAL 🚨",
SeverityLevel::Warning => "WARNING ‼️",
SeverityLevel::Ok => "OK ✅",
}
}
fn resource_link(
resource_type: ResourceTargetVariant,
id: &str,
) -> String {
komodo_client::entities::resource_link(
&core_config().host,
resource_type,
id,
)
}
/// Standard message content format
/// used by Ntfy, Pushover.
fn standard_alert_content(alert: &Alert) -> String {
let level = fmt_level(alert.level);
match &alert.data {
AlertData::Test { id, name } => {
let link = resource_link(ResourceTargetVariant::Alerter, id);
format!(
"{level} | If you see this message, then Alerter {name} is working\n{link}",
)
}
AlertData::ServerVersionMismatch {
id,
name,
region,
server_version,
core_version,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | {name}{region} | Periphery version now matches Core version ✅\n{link}"
)
}
_ => {
format!(
"{level} | {name}{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}\n{link}"
)
}
}
}
AlertData::ServerUnreachable {
id,
name,
region,
err,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
match alert.level {
SeverityLevel::Ok => {
format!("{level} | {name}{region} is now connected\n{link}")
}
SeverityLevel::Critical => {
let err = err
.as_ref()
.map(|e| format!("\nerror: {e:#?}"))
.unwrap_or_default();
format!(
"{level} | {name}{region} is unreachable ❌\n{link}{err}"
)
}
_ => unreachable!(),
}
}
AlertData::ServerCpu {
id,
name,
region,
percentage,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
format!(
"{level} | {name}{region} cpu usage at {percentage:.1}%\n{link}",
)
}
AlertData::ServerMem {
id,
name,
region,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
let percentage = 100.0 * used_gb / total_gb;
format!(
"{level} | {name}{region} memory usage at {percentage:.1}%💾\n\nUsing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
)
}
AlertData::ServerDisk {
id,
name,
region,
path,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let link = resource_link(ResourceTargetVariant::Server, id);
let percentage = 100.0 * used_gb / total_gb;
format!(
"{level} | {name}{region} disk usage at {percentage:.1}%💿\nmount point: {path:?}\nusing {used_gb:.1} GiB / {total_gb:.1} GiB\n{link}",
)
}
AlertData::ContainerStateChange {
id,
name,
server_id: _server_id,
server_name,
from,
to,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
let to_state = fmt_docker_container_state(to);
format!(
"📦Deployment {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
)
}
AlertData::DeploymentImageUpdateAvailable {
id,
name,
server_id: _server_id,
server_name,
image,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
format!(
"⬆ Deployment {name} has an update available\nserver: {server_name}\nimage: {image}\n{link}",
)
}
AlertData::DeploymentAutoUpdated {
id,
name,
server_id: _server_id,
server_name,
image,
} => {
let link = resource_link(ResourceTargetVariant::Deployment, id);
format!(
"⬆ Deployment {name} was updated automatically\nserver: {server_name}\nimage: {image}\n{link}",
)
}
AlertData::StackStateChange {
id,
name,
server_id: _server_id,
server_name,
from,
to,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
let to_state = fmt_stack_state(to);
format!(
"🥞 Stack {name} is now {to_state}\nserver: {server_name}\nprevious: {from}\n{link}",
)
}
AlertData::StackImageUpdateAvailable {
id,
name,
server_id: _server_id,
server_name,
service,
image,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
format!(
"⬆ Stack {name} has an update available\nserver: {server_name}\nservice: {service}\nimage: {image}\n{link}",
)
}
AlertData::StackAutoUpdated {
id,
name,
server_id: _server_id,
server_name,
images,
} => {
let link = resource_link(ResourceTargetVariant::Stack, id);
let images_label =
if images.len() > 1 { "images" } else { "image" };
let images_str = images.join(", ");
format!(
"⬆ Stack {name} was updated automatically ⏫\nserver: {server_name}\n{images_label}: {images_str}\n{link}",
)
}
AlertData::AwsBuilderTerminationFailed {
instance_id,
message,
} => {
format!(
"{level} | Failed to terminate AWS builder instance\ninstance id: {instance_id}\n{message}",
)
}
AlertData::ResourceSyncPendingUpdates { id, name } => {
let link =
resource_link(ResourceTargetVariant::ResourceSync, id);
format!(
"{level} | Pending resource sync updates on {name}\n{link}",
)
}
AlertData::BuildFailed { id, name, version } => {
let link = resource_link(ResourceTargetVariant::Build, id);
format!(
"{level} | Build {name} failed\nversion: v{version}\n{link}",
)
}
AlertData::RepoBuildFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Repo, id);
format!("{level} | Repo build for {name} failed\n{link}",)
}
AlertData::ProcedureFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Procedure, id);
format!("{level} | Procedure {name} failed\n{link}")
}
AlertData::ActionFailed { id, name } => {
let link = resource_link(ResourceTargetVariant::Action, id);
format!("{level} | Action {name} failed\n{link}")
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let link = resource_link(*resource_type, id);
format!(
"{level} | {name} ({resource_type}) | Scheduled run started 🕝\n{link}"
)
}
AlertData::Custom { message, details } => {
format!(
"{level} | {message}{}",
if details.is_empty() {
format_args!("")
} else {
format_args!("\n{details}")
}
)
}
AlertData::None {} => Default::default(),
}
}

View File

@@ -0,0 +1,75 @@
use std::sync::OnceLock;
use super::*;
pub async fn send_alert(
url: &str,
email: Option<&str>,
alert: &Alert,
) -> anyhow::Result<()> {
let content = standard_alert_content(alert);
if content.is_empty() {
return Ok(());
}
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut url_interpolated = url.to_string();
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator.interpolate_string(&mut url_interpolated)?;
send_message(&url_interpolated, email, content)
.await
.map_err(|e| {
let replacers = interpolator
.secret_replacers
.into_iter()
.collect::<Vec<_>>();
let sanitized_error =
svi::replace_in_string(&format!("{e:?}"), &replacers);
anyhow::Error::msg(format!(
"Error with slack request: {sanitized_error}"
))
})
}
async fn send_message(
url: &str,
email: Option<&str>,
content: String,
) -> anyhow::Result<()> {
let mut request = http_client()
.post(url)
.header("Title", "Komodo Alert")
.body(content);
if let Some(email) = email {
request = request.header("X-Email", email);
}
let response =
request.send().await.context("Failed to send message")?;
let status = response.status();
if status.is_success() {
debug!("ntfy alert sent successfully: {}", status);
Ok(())
} else {
let text = response.text().await.with_context(|| {
format!(
"Failed to send message to ntfy | {status} | failed to get response text"
)
})?;
Err(anyhow!(
"Failed to send message to ntfy | {status} | {text}",
))
}
}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}

View File

@@ -0,0 +1,74 @@
use std::sync::OnceLock;
use super::*;
pub async fn send_alert(
url: &str,
alert: &Alert,
) -> anyhow::Result<()> {
let content = standard_alert_content(alert);
if content.is_empty() {
return Ok(());
}
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut url_interpolated = url.to_string();
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator.interpolate_string(&mut url_interpolated)?;
send_message(&url_interpolated, content).await.map_err(|e| {
let replacers = interpolator
.secret_replacers
.into_iter()
.collect::<Vec<_>>();
let sanitized_error =
svi::replace_in_string(&format!("{e:?}"), &replacers);
anyhow::Error::msg(format!(
"Error with slack request: {sanitized_error}"
))
})
}
async fn send_message(
url: &str,
content: String,
) -> anyhow::Result<()> {
// pushover needs all information to be encoded in the URL. At minimum they need
// the user key, the application token, and the message (url encoded).
// other optional params here: https://pushover.net/api (just add them to the
// webhook url along with the application token and the user key).
let content = [("message", content)];
let response = http_client()
.post(url)
.form(&content)
.send()
.await
.context("Failed to send message")?;
let status = response.status();
if status.is_success() {
debug!("pushover alert sent successfully: {}", status);
Ok(())
} else {
let text = response.text().await.with_context(|| {
format!(
"Failed to send message to pushover | {status} | failed to get response text"
)
})?;
Err(anyhow!(
"Failed to send message to pushover | {} | {}",
status,
text
))
}
}
fn http_client() -> &'static reqwest::Client {
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
CLIENT.get_or_init(reqwest::Client::new)
}

498
bin/core/src/alert/slack.rs Normal file
View File

@@ -0,0 +1,498 @@
use ::slack::types::OwnedBlock as Block;
use super::*;
pub async fn send_alert(
url: &str,
alert: &Alert,
) -> anyhow::Result<()> {
let level = fmt_level(alert.level);
let (text, blocks): (_, Option<_>) = match &alert.data {
AlertData::Test { id, name } => {
let text = format!(
"{level} | If you see this message, then Alerter *{name}* is *working*"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"If you see this message, then Alerter *{name}* is *working*"
)),
Block::section(resource_link(
ResourceTargetVariant::Alerter,
id,
)),
];
(text, blocks.into())
}
AlertData::ServerVersionMismatch {
id,
name,
region,
server_version,
core_version,
} => {
let region = fmt_region(region);
let text = match alert.level {
SeverityLevel::Ok => {
format!(
"{level} | *{name}*{region} | Periphery version now matches Core version ✅"
)
}
_ => {
format!(
"{level} | *{name}*{region} | Version mismatch detected ⚠️\nPeriphery: {server_version} | Core: {core_version}"
)
}
};
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
AlertData::ServerUnreachable {
id,
name,
region,
err,
} => {
let region = fmt_region(region);
match alert.level {
SeverityLevel::Ok => {
let text =
format!("{level} | *{name}*{region} is now *connected*");
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} is now *connnected*"
)),
];
(text, blocks.into())
}
SeverityLevel::Critical => {
let text =
format!("{level} | *{name}*{region} is *unreachable* ❌");
let err = err
.as_ref()
.map(|e| format!("\nerror: {e:#?}"))
.unwrap_or_default();
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} is *unreachable* ❌{err}"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
_ => unreachable!(),
}
}
AlertData::ServerCpu {
id,
name,
region,
percentage,
} => {
let region = fmt_region(region);
match alert.level {
SeverityLevel::Ok => {
let text = format!(
"{level} | *{name}*{region} cpu usage at *{percentage:.1}%*"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} cpu usage at *{percentage:.1}%*"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
_ => {
let text = format!(
"{level} | *{name}*{region} cpu usage at *{percentage:.1}%* 📈"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} cpu usage at *{percentage:.1}%* 📈"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
}
}
AlertData::ServerMem {
id,
name,
region,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let percentage = 100.0 * used_gb / total_gb;
match alert.level {
SeverityLevel::Ok => {
let text = format!(
"{level} | *{name}*{region} memory usage at *{percentage:.1}%* 💾"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} memory usage at *{percentage:.1}%* 💾"
)),
Block::section(format!(
"using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
_ => {
let text = format!(
"{level} | *{name}*{region} memory usage at *{percentage:.1}%* 💾"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} memory usage at *{percentage:.1}%* 💾"
)),
Block::section(format!(
"using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
}
}
AlertData::ServerDisk {
id,
name,
region,
path,
used_gb,
total_gb,
} => {
let region = fmt_region(region);
let percentage = 100.0 * used_gb / total_gb;
match alert.level {
SeverityLevel::Ok => {
let text = format!(
"{level} | *{name}*{region} disk usage at *{percentage:.1}%* | mount point: *{path:?}* 💿"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} disk usage at *{percentage:.1}%* 💿"
)),
Block::section(format!(
"mount point: {path:?} | using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
_ => {
let text = format!(
"{level} | *{name}*{region} disk usage at *{percentage:.1}%* | mount point: *{path:?}* 💿"
);
let blocks = vec![
Block::header(level),
Block::section(format!(
"*{name}*{region} disk usage at *{percentage:.1}%* 💿"
)),
Block::section(format!(
"mount point: {path:?} | using *{used_gb:.1} GiB* / *{total_gb:.1} GiB*"
)),
Block::section(resource_link(
ResourceTargetVariant::Server,
id,
)),
];
(text, blocks.into())
}
}
}
AlertData::ContainerStateChange {
name,
server_name,
from,
to,
id,
..
} => {
let to = fmt_docker_container_state(to);
let text = format!("📦 Container *{name}* is now *{to}*");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"server: {server_name}\nprevious: {from}",
)),
Block::section(resource_link(
ResourceTargetVariant::Deployment,
id,
)),
];
(text, blocks.into())
}
AlertData::DeploymentImageUpdateAvailable {
id,
name,
server_name,
server_id: _server_id,
image,
} => {
let text =
format!("⬆ Deployment *{name}* has an update available");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"server: *{server_name}*\nimage: *{image}*",
)),
Block::section(resource_link(
ResourceTargetVariant::Deployment,
id,
)),
];
(text, blocks.into())
}
AlertData::DeploymentAutoUpdated {
id,
name,
server_name,
server_id: _server_id,
image,
} => {
let text =
format!("⬆ Deployment *{name}* was updated automatically ⏫");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"server: *{server_name}*\nimage: *{image}*",
)),
Block::section(resource_link(
ResourceTargetVariant::Deployment,
id,
)),
];
(text, blocks.into())
}
AlertData::StackStateChange {
name,
server_name,
from,
to,
id,
..
} => {
let to = fmt_stack_state(to);
let text = format!("🥞 Stack *{name}* is now *{to}*");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"server: *{server_name}*\nprevious: *{from}*",
)),
Block::section(resource_link(
ResourceTargetVariant::Stack,
id,
)),
];
(text, blocks.into())
}
AlertData::StackImageUpdateAvailable {
id,
name,
server_name,
server_id: _server_id,
service,
image,
} => {
let text = format!("⬆ Stack *{name}* has an update available");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"server: *{server_name}*\nservice: *{service}*\nimage: *{image}*",
)),
Block::section(resource_link(
ResourceTargetVariant::Stack,
id,
)),
];
(text, blocks.into())
}
AlertData::StackAutoUpdated {
id,
name,
server_name,
server_id: _server_id,
images,
} => {
let text =
format!("⬆ Stack *{name}* was updated automatically ⏫");
let images_label =
if images.len() > 1 { "images" } else { "image" };
let images = images.join(", ");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"server: *{server_name}*\n{images_label}: *{images}*",
)),
Block::section(resource_link(
ResourceTargetVariant::Stack,
id,
)),
];
(text, blocks.into())
}
AlertData::AwsBuilderTerminationFailed {
instance_id,
message,
} => {
let text = format!(
"{level} | Failed to terminated AWS builder instance "
);
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"instance id: *{instance_id}*\n{message}"
)),
];
(text, blocks.into())
}
AlertData::ResourceSyncPendingUpdates { id, name } => {
let text = format!(
"{level} | Pending resource sync updates on *{name}*"
);
let blocks = vec![
Block::header(text.clone()),
Block::section(format!(
"sync id: *{id}*\nsync name: *{name}*",
)),
Block::section(resource_link(
ResourceTargetVariant::ResourceSync,
id,
)),
];
(text, blocks.into())
}
AlertData::BuildFailed { id, name, version } => {
let text = format!("{level} | Build {name} has failed");
let blocks = vec![
Block::header(text.clone()),
Block::section(format!("version: *v{version}*",)),
Block::section(resource_link(
ResourceTargetVariant::Build,
id,
)),
];
(text, blocks.into())
}
AlertData::RepoBuildFailed { id, name } => {
let text =
format!("{level} | Repo build for *{name}* has *failed*");
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(
ResourceTargetVariant::Repo,
id,
)),
];
(text, blocks.into())
}
AlertData::ProcedureFailed { id, name } => {
let text = format!("{level} | Procedure *{name}* has *failed*");
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(
ResourceTargetVariant::Procedure,
id,
)),
];
(text, blocks.into())
}
AlertData::ActionFailed { id, name } => {
let text = format!("{level} | Action *{name}* has *failed*");
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(
ResourceTargetVariant::Action,
id,
)),
];
(text, blocks.into())
}
AlertData::ScheduleRun {
resource_type,
id,
name,
} => {
let text = format!(
"{level} | *{name}* ({resource_type}) | Scheduled run started 🕝"
);
let blocks = vec![
Block::header(text.clone()),
Block::section(resource_link(*resource_type, id)),
];
(text, blocks.into())
}
AlertData::Custom { message, details } => {
let text = format!("{level} | {message}");
let blocks =
vec![Block::header(text.clone()), Block::section(details)];
(text, blocks.into())
}
AlertData::None {} => Default::default(),
};
if text.is_empty() {
return Ok(());
}
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut url_interpolated = url.to_string();
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator.interpolate_string(&mut url_interpolated)?;
let slack = ::slack::Client::new(url_interpolated);
slack
.send_owned_message_single(&text, None, blocks.as_deref())
.await
.map_err(|e| {
let replacers = interpolator
.secret_replacers
.into_iter()
.collect::<Vec<_>>();
let sanitized_error =
svi::replace_in_string(&format!("{e:?}"), &replacers);
anyhow::Error::msg(format!(
"Error with slack request: {sanitized_error}"
))
})?;
Ok(())
}

View File

@@ -1,12 +1,14 @@
use std::{sync::OnceLock, time::Instant};
use anyhow::anyhow;
use axum::{http::HeaderMap, routing::post, Router};
use axum_extra::{headers::ContentType, TypedHeader};
use monitor_client::{api::auth::*, entities::user::User};
use resolver_api::{derive::Resolver, Resolve, Resolver};
use axum::{Router, extract::Path, http::HeaderMap, routing::post};
use derive_variants::{EnumVariants, ExtractVariant};
use komodo_client::{api::auth::*, entities::user::User};
use reqwest::StatusCode;
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serror::Json;
use serde_json::json;
use serror::{AddStatusCode, Json};
use typeshare::typeshare;
use uuid::Uuid;
@@ -15,62 +17,94 @@ use crate::{
get_user_id_from_headers,
github::{self, client::github_oauth_client},
google::{self, client::google_oauth_client},
oidc::{self, client::oidc_client},
},
config::core_config,
helpers::query::get_user,
state::{jwt_client, State},
state::jwt_client,
};
use super::Variant;
#[derive(Default)]
pub struct AuthArgs {
pub headers: HeaderMap,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Resolver)]
#[resolver_target(State)]
#[resolver_args(HeaderMap)]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
)]
#[args(AuthArgs)]
#[response(Response)]
#[error(serror::Error)]
#[variant_derive(Debug)]
#[serde(tag = "type", content = "params")]
#[allow(clippy::enum_variant_names, clippy::large_enum_variant)]
pub enum AuthRequest {
GetLoginOptions(GetLoginOptions),
CreateLocalUser(CreateLocalUser),
SignUpLocalUser(SignUpLocalUser),
LoginLocalUser(LoginLocalUser),
ExchangeForJwt(ExchangeForJwt),
GetUser(GetUser),
}
pub fn router() -> Router {
let mut router = Router::new().route("/", post(handler));
let mut router = Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler));
if core_config().local_auth {
info!("🔑 Local Login Enabled");
}
if github_oauth_client().is_some() {
info!("🔑 Github Login Enabled");
router = router.nest("/github", github::router())
}
if google_oauth_client().is_some() {
info!("🔑 Google Login Enabled");
router = router.nest("/google", google::router())
}
if core_config().oidc_enabled {
info!("🔑 OIDC Login Enabled");
router = router.nest("/oidc", oidc::router())
}
router
}
#[instrument(name = "AuthHandler", level = "debug", skip(headers))]
async fn variant_handler(
headers: HeaderMap,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: AuthRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(headers, Json(req)).await
}
async fn handler(
headers: HeaderMap,
Json(request): Json<AuthRequest>,
) -> serror::Result<(TypedHeader<ContentType>, String)> {
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!("/auth request {req_id} | METHOD: {}", request.req_type());
let res = State.resolve_request(request, headers).await.map_err(
|e| match e {
resolver_api::Error::Serialization(e) => {
anyhow!("{e:?}").context("response serialization error")
}
resolver_api::Error::Inner(e) => e,
},
debug!(
"/auth request {req_id} | METHOD: {:?}",
request.extract_variant()
);
let res = request.resolve(&AuthArgs { headers }).await;
if let Err(e) = &res {
debug!("/auth request {req_id} | error: {e:#}");
debug!("/auth request {req_id} | error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/auth request {req_id} | resolve time: {elapsed:?}");
Ok((TypedHeader(ContentType::json()), res?))
res.map(|res| res.0)
}
fn login_options_reponse() -> &'static GetLoginOptionsResponse {
@@ -81,48 +115,45 @@ fn login_options_reponse() -> &'static GetLoginOptionsResponse {
let config = core_config();
GetLoginOptionsResponse {
local: config.local_auth,
github: config.github_oauth.enabled
&& !config.github_oauth.id.is_empty()
&& !config.github_oauth.secret.is_empty(),
google: config.google_oauth.enabled
&& !config.google_oauth.id.is_empty()
&& !config.google_oauth.secret.is_empty(),
github: github_oauth_client().is_some(),
google: google_oauth_client().is_some(),
oidc: oidc_client().load().is_some(),
registration_disabled: config.disable_user_registration,
}
})
}
impl Resolve<GetLoginOptions, HeaderMap> for State {
#[instrument(name = "GetLoginOptions", level = "debug", skip(self))]
impl Resolve<AuthArgs> for GetLoginOptions {
async fn resolve(
&self,
_: GetLoginOptions,
_: HeaderMap,
) -> anyhow::Result<GetLoginOptionsResponse> {
self,
_: &AuthArgs,
) -> serror::Result<GetLoginOptionsResponse> {
Ok(*login_options_reponse())
}
}
impl Resolve<ExchangeForJwt, HeaderMap> for State {
#[instrument(name = "ExchangeForJwt", level = "debug", skip(self))]
impl Resolve<AuthArgs> for ExchangeForJwt {
async fn resolve(
&self,
ExchangeForJwt { token }: ExchangeForJwt,
_: HeaderMap,
) -> anyhow::Result<ExchangeForJwtResponse> {
let jwt = jwt_client().redeem_exchange_token(&token).await?;
let res = ExchangeForJwtResponse { jwt };
Ok(res)
self,
_: &AuthArgs,
) -> serror::Result<ExchangeForJwtResponse> {
jwt_client()
.redeem_exchange_token(&self.token)
.await
.map_err(Into::into)
}
}
impl Resolve<GetUser, HeaderMap> for State {
#[instrument(name = "GetUser", level = "debug", skip(self))]
impl Resolve<AuthArgs> for GetUser {
async fn resolve(
&self,
GetUser {}: GetUser,
headers: HeaderMap,
) -> anyhow::Result<User> {
let user_id = get_user_id_from_headers(&headers).await?;
get_user(&user_id).await
self,
AuthArgs { headers }: &AuthArgs,
) -> serror::Result<User> {
let user_id = get_user_id_from_headers(headers)
.await
.status_code(StatusCode::UNAUTHORIZED)?;
get_user(&user_id)
.await
.status_code(StatusCode::UNAUTHORIZED)
}
}

View File

@@ -0,0 +1,439 @@
use std::{
collections::HashSet,
path::{Path, PathBuf},
sync::OnceLock,
};
use anyhow::Context;
use command::run_komodo_command;
use config::merge_objects;
use database::mungos::{
by_id::update_one_by_id, mongodb::bson::to_document,
};
use interpolate::Interpolator;
use komodo_client::{
api::{
execute::{BatchExecutionResponse, BatchRunAction, RunAction},
user::{CreateApiKey, CreateApiKeyResponse, DeleteApiKey},
},
entities::{
FileFormat, JsonObject,
action::Action,
alert::{Alert, AlertData, SeverityLevel},
config::core::CoreConfig,
komodo_timestamp,
permission::PermissionLevel,
random_string,
update::Update,
user::action_user,
},
parsers::parse_key_value_list,
};
use resolver_api::Resolve;
use tokio::fs;
use crate::{
alert::send_alerts,
api::{execute::ExecuteRequest, user::UserArgs},
config::core_config,
helpers::{
query::{VariablesAndSecrets, get_variables_and_secrets},
update::update_update,
},
permission::get_check_permissions,
resource::refresh_action_state_cache,
state::{action_states, db_client},
};
use super::ExecuteArgs;
impl super::BatchExecute for BatchRunAction {
type Resource = Action;
fn single_request(action: String) -> ExecuteRequest {
ExecuteRequest::RunAction(RunAction {
action,
args: Default::default(),
})
}
}
impl Resolve<ExecuteArgs> for BatchRunAction {
#[instrument(
"BatchRunAction",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern,
)
)]
async fn resolve(
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchRunAction>(&self.pattern, user)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for RunAction {
#[instrument(
"RunAction",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
action = self.action,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let mut action = get_check_permissions::<Action>(
&self.action,
user,
PermissionLevel::Execute.into(),
)
.await?;
// get the action state for the action (or insert default).
let action_state = action_states()
.action
.get_or_insert_default(&action.id)
.await;
// This will set action state back to default when dropped.
// Will also check to ensure action not already busy before updating.
let _action_guard = action_state.update_custom(
|state| state.running += 1,
|state| state.running -= 1,
false,
)?;
let mut update = update.clone();
update_update(update.clone()).await?;
let default_args = parse_action_arguments(
&action.config.arguments,
action.config.arguments_format,
)
.context("Failed to parse default Action arguments")?;
let args = merge_objects(
default_args,
self.args.unwrap_or_default(),
true,
true,
)
.context("Failed to merge request args with default args")?;
let args = serde_json::to_string(&args)
.context("Failed to serialize action run arguments")?;
let CreateApiKeyResponse { key, secret } = CreateApiKey {
name: update.id.clone(),
expires: 0,
}
.resolve(&UserArgs {
user: action_user().to_owned(),
})
.await?;
let contents = &mut action.config.file_contents;
// Wrap the file contents in the execution context.
*contents = full_contents(contents, &args, &key, &secret);
let replacers =
interpolate(contents, &mut update, key.clone(), secret.clone())
.await?
.into_iter()
.collect::<Vec<_>>();
let file = format!("{}.ts", random_string(10));
let path = core_config().action_directory.join(&file);
secret_file::write_async(&path, contents)
.await
.with_context(|| {
format!("Failed to write action file to {path:?}")
})?;
let CoreConfig { ssl_enabled, .. } = core_config();
let https_cert_flag = if *ssl_enabled {
" --unsafely-ignore-certificate-errors=localhost"
} else {
""
};
let reload = if action.config.reload_deno_deps {
" --reload"
} else {
""
};
let mut res = run_komodo_command(
// Keep this stage name as is, the UI will find the latest update log by matching the stage name
"Execute Action",
None,
format!(
"deno run --allow-all{https_cert_flag}{reload} {}",
path.display()
),
)
.await;
res.stdout = svi::replace_in_string(&res.stdout, &replacers)
.replace(&key, "<ACTION_API_KEY>");
res.stderr = svi::replace_in_string(&res.stderr, &replacers)
.replace(&secret, "<ACTION_API_SECRET>");
cleanup_run(file + ".js", &path).await;
if let Err(e) = (DeleteApiKey { key })
.resolve(&UserArgs {
user: action_user().to_owned(),
})
.await
{
warn!(
"Failed to delete API key after action execution | {:#}",
e.error
);
};
update.logs.push(res);
update.finalize();
// Need to manually update the update before cache refresh,
// and before broadcast with update_update.
// The Err case of to_document should be unreachable,
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().updates,
&update.id,
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
refresh_action_state_cache().await;
}
update_update(update.clone()).await?;
if !update.success && action.config.failure_alert {
let target = update.target.clone();
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::ActionFailed {
id: action.id,
name: action.name,
},
};
send_alerts(&[alert]).await
});
}
Ok(update)
}
}
#[instrument("Interpolate", skip(contents, update, secret))]
async fn interpolate(
contents: &mut String,
update: &mut Update,
key: String,
secret: String,
) -> serror::Result<HashSet<(String, String)>> {
let VariablesAndSecrets {
variables,
mut secrets,
} = get_variables_and_secrets().await?;
secrets.insert(String::from("ACTION_API_KEY"), key);
secrets.insert(String::from("ACTION_API_SECRET"), secret);
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator
.interpolate_string(contents)?
.push_logs(&mut update.logs);
Ok(interpolator.secret_replacers)
}
fn full_contents(
contents: &str,
// Pre-serialized to JSON string.
args: &str,
key: &str,
secret: &str,
) -> String {
let CoreConfig {
port, ssl_enabled, ..
} = core_config();
let protocol = if *ssl_enabled { "https" } else { "http" };
let base_url = format!("{protocol}://localhost:{port}");
format!(
"import {{ KomodoClient, Types }} from '{base_url}/client/lib.js';
import * as __YAML__ from 'jsr:@std/yaml';
import * as __TOML__ from 'jsr:@std/toml';
const YAML = {{
stringify: __YAML__.stringify,
parse: __YAML__.parse,
parseAll: __YAML__.parseAll,
parseDockerCompose: __YAML__.parse,
}}
const TOML = {{
stringify: __TOML__.stringify,
parse: __TOML__.parse,
parseResourceToml: __TOML__.parse,
parseCargoToml: __TOML__.parse,
}}
const ARGS = {args};
const komodo = KomodoClient('{base_url}', {{
type: 'api-key',
params: {{ key: '{key}', secret: '{secret}' }}
}});
async function main() {{
{contents}
console.log('🦎 Action completed successfully 🦎');
}}
main()
.catch(error => {{
console.error('🚨 Action exited early with errors 🚨')
if (error.status !== undefined && error.result !== undefined) {{
console.error('Status:', error.status);
console.error(JSON.stringify(error.result, null, 2));
}} else {{
console.error(error);
}}
Deno.exit(1)
}});"
)
}
/// Cleans up file at given path.
/// ALSO if $DENO_DIR is set,
/// will clean up the generated file matching "file"
#[instrument("CleanupRun")]
async fn cleanup_run(file: String, path: &Path) {
if let Err(e) = fs::remove_file(path).await {
warn!(
"Failed to delete action file after action execution | {e:#}"
);
}
// If $DENO_DIR is set (will be in container),
// will clean up the generated file matching "file" (NOT under path)
let Some(deno_dir) = deno_dir() else {
return;
};
delete_file(deno_dir.join("gen/file"), file).await;
}
fn deno_dir() -> Option<&'static Path> {
static DENO_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
DENO_DIR
.get_or_init(|| {
let deno_dir = std::env::var("DENO_DIR").ok()?;
Some(PathBuf::from(&deno_dir))
})
.as_deref()
}
/// file is just the terminating file path,
/// it may be nested multiple folder under path,
/// this will find the nested file and delete it.
/// Assumes the file is only there once.
fn delete_file(
dir: PathBuf,
file: String,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = bool> + Send>>
{
Box::pin(async move {
let Ok(mut dir) = fs::read_dir(dir).await else {
return false;
};
// Collect the nested folders for recursing
// only after checking all the files in directory.
let mut folders = Vec::<PathBuf>::new();
while let Ok(Some(entry)) = dir.next_entry().await {
let Ok(meta) = entry.metadata().await else {
continue;
};
if meta.is_file() {
let Ok(name) = entry.file_name().into_string() else {
continue;
};
if name == file {
if let Err(e) = fs::remove_file(entry.path()).await {
warn!(
"Failed to clean up generated file after action execution | {e:#}"
);
};
return true;
}
} else {
folders.push(entry.path());
}
}
if folders.len() == 1 {
// unwrap ok, folders definitely is not empty
let folder = folders.pop().unwrap();
delete_file(folder, file).await
} else {
// Check folders with file.clone
for folder in folders {
if delete_file(folder, file.clone()).await {
return true;
}
}
false
}
})
}
fn parse_action_arguments(
args: &str,
format: FileFormat,
) -> anyhow::Result<JsonObject> {
match format {
FileFormat::KeyValue => {
let args = parse_key_value_list(args)
.context("Failed to parse args as key value list")?
.into_iter()
.map(|(k, v)| (k, serde_json::Value::String(v)))
.collect();
Ok(args)
}
FileFormat::Toml => toml::from_str(args)
.context("Failed to parse Toml to Action args"),
FileFormat::Yaml => serde_yaml_ng::from_str(args)
.context("Failed to parse Yaml to action args"),
FileFormat::Json => serde_json::from_str(args)
.context("Failed to parse Json to action args"),
}
}

View File

@@ -0,0 +1,190 @@
use anyhow::{Context, anyhow};
use formatting::format_serror;
use futures_util::{
StreamExt, TryStreamExt, stream::FuturesUnordered,
};
use komodo_client::{
api::execute::{SendAlert, TestAlerter},
entities::{
alert::{Alert, AlertData, AlertDataVariant, SeverityLevel},
alerter::Alerter,
komodo_timestamp,
permission::PermissionLevel,
},
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{
alert::send_alert_to_alerter, helpers::update::update_update,
permission::get_check_permissions, resource::list_full_for_user,
};
use super::ExecuteArgs;
impl Resolve<ExecuteArgs> for TestAlerter {
#[instrument(
"TestAlerter",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
alerter = self.alerter,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
let alerter = get_check_permissions::<Alerter>(
&self.alerter,
user,
PermissionLevel::Execute.into(),
)
.await?;
let mut update = update.clone();
if !alerter.config.enabled {
update.push_error_log(
"Test Alerter",
String::from(
"Alerter is disabled. Enable the Alerter to send alerts.",
),
);
update.finalize();
update_update(update.clone()).await?;
return Ok(update);
}
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
ts,
resolved: true,
level: SeverityLevel::Ok,
target: update.target.clone(),
data: AlertData::Test {
id: alerter.id.clone(),
name: alerter.name.clone(),
},
resolved_ts: Some(ts),
};
if let Err(e) = send_alert_to_alerter(&alerter, &alert).await {
update.push_error_log("Test Alerter", format_serror(&e.into()));
} else {
update.push_simple_log("Test Alerter", String::from("Alert sent successfully. It should be visible at your alerting destination."));
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
//
impl Resolve<ExecuteArgs> for SendAlert {
#[instrument(
"SendAlert",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
request = format!("{self:?}"),
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
let alerters =
list_full_for_user::<Alerter>(Default::default(), user, &[])
.await?
.into_iter()
.filter(|a| {
a.config.enabled
&& (self.alerters.is_empty()
|| self.alerters.contains(&a.name)
|| self.alerters.contains(&a.id))
&& (a.config.alert_types.is_empty()
|| a
.config
.alert_types
.contains(&AlertDataVariant::Custom))
})
.collect::<Vec<_>>();
let alerters = if user.admin {
alerters
} else {
// Only keep alerters with execute permissions
alerters
.into_iter()
.map(|alerter| async move {
get_check_permissions::<Alerter>(
&alerter.id,
user,
PermissionLevel::Execute.into(),
)
.await
})
.collect::<FuturesUnordered<_>>()
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.collect()
};
if alerters.is_empty() {
return Err(anyhow!(
"Could not find any valid alerters to send to, this required Execute permissions on the Alerter"
).status_code(StatusCode::BAD_REQUEST));
}
let mut update = update.clone();
let ts = komodo_timestamp();
let alert = Alert {
id: Default::default(),
ts,
resolved: true,
level: self.level,
target: update.target.clone(),
data: AlertData::Custom {
message: self.message,
details: self.details,
},
resolved_ts: Some(ts),
};
update.push_simple_log(
"Send alert",
serde_json::to_string_pretty(&alert)
.context("Failed to serialize alert to JSON")?,
);
if let Err(e) = alerters
.iter()
.map(|alerter| send_alert_to_alerter(alerter, &alert))
.collect::<FuturesUnordered<_>>()
.try_collect::<Vec<_>>()
.await
{
update.push_error_log("Send Error", format_serror(&e.into()));
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}

View File

@@ -1,26 +1,11 @@
use std::{collections::HashSet, future::IntoFuture, time::Duration};
use anyhow::{anyhow, Context};
use formatting::format_serror;
use futures::future::join_all;
use monitor_client::{
api::execute::{CancelBuild, Deploy, RunBuild},
entities::{
alert::{Alert, AlertData},
all_logs_success,
build::{Build, ImageRegistry, StandardRegistryConfig},
builder::{Builder, BuilderConfig},
config::core::{AwsEcrConfig, AwsEcrConfigWithCredentials},
deployment::DeploymentState,
monitor_timestamp,
permission::PermissionLevel,
server::stats::SeverityLevel,
to_monitor_name,
update::{Log, Update},
user::{auto_redeploy_user, User},
},
use std::{
collections::{HashMap, HashSet},
future::IntoFuture,
time::Duration,
};
use mungos::{
use anyhow::{Context, anyhow};
use database::mungos::{
by_id::update_one_by_id,
find::find_collect,
mongodb::{
@@ -28,46 +13,125 @@ use mungos::{
options::FindOneOptions,
},
};
use periphery_client::api::{self, git::RepoActionResponseV1_13};
use formatting::format_serror;
use futures_util::future::join_all;
use interpolate::Interpolator;
use komodo_client::{
api::execute::{
BatchExecutionResponse, BatchRunBuild, CancelBuild, Deploy,
RunBuild,
},
entities::{
alert::{Alert, AlertData, SeverityLevel},
all_logs_success,
build::{Build, BuildConfig},
builder::{Builder, BuilderConfig},
deployment::DeploymentState,
komodo_timestamp, optional_string,
permission::PermissionLevel,
repo::Repo,
update::{Log, Update},
user::auto_redeploy_user,
},
};
use periphery_client::api;
use resolver_api::Resolve;
use tokio_util::sync::CancellationToken;
use uuid::Uuid;
use crate::{
cloud::aws::ecr,
config::core_config,
alert::send_alerts,
helpers::{
alert::send_alerts,
builder::{cleanup_builder_instance, get_builder_periphery},
build_git_token,
builder::{cleanup_builder_instance, connect_builder_periphery},
channel::build_cancel_channel,
git_token,
query::{get_deployment_state, get_global_variables},
query::{
VariablesAndSecrets, get_deployment_state,
get_variables_and_secrets,
},
registry_token,
update::update_update,
update::{init_execution_update, update_update},
},
permission::get_check_permissions,
resource::{self, refresh_build_state_cache},
state::{action_states, db_client, State},
state::{action_states, db_client},
};
use crate::helpers::update::init_execution_update;
use super::{ExecuteArgs, ExecuteRequest};
use super::ExecuteRequest;
impl super::BatchExecute for BatchRunBuild {
type Resource = Build;
fn single_request(build: String) -> ExecuteRequest {
ExecuteRequest::RunBuild(RunBuild { build })
}
}
impl Resolve<RunBuild, (User, Update)> for State {
#[instrument(name = "RunBuild", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for BatchRunBuild {
#[instrument(
"BatchRunBuild",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern,
)
)]
async fn resolve(
&self,
RunBuild { build }: RunBuild,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let mut build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Execute,
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchRunBuild>(&self.pattern, user)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for RunBuild {
#[instrument(
"RunBuild",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
build = self.build,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let mut build = get_check_permissions::<Build>(
&self.build,
user,
PermissionLevel::Execute.into(),
)
.await?;
let mut repo = if !build.config.files_on_host
&& !build.config.linked_repo.is_empty()
{
crate::resource::get::<Repo>(&build.config.linked_repo)
.await?
.into()
} else {
None
};
let VariablesAndSecrets {
mut variables,
secrets,
} = get_variables_and_secrets().await?;
// Add the $VERSION to variables. Use with [[$VERSION]]
variables.insert(
String::from("$VERSION"),
build.config.version.to_string(),
);
if build.config.builder_id.is_empty() {
return Err(anyhow!("Must attach builder to RunBuild"));
return Err(anyhow!("Must attach builder to RunBuild").into());
}
// get the action state for the build (or insert default).
@@ -82,21 +146,17 @@ impl Resolve<RunBuild, (User, Update)> for State {
if build.config.auto_increment_version {
build.config.version.increment();
}
let mut update = update.clone();
update.version = build.config.version;
update_update(update.clone()).await?;
let git_token = git_token(
&build.config.git_provider,
&build.config.git_account,
|https| build.config.git_https = https,
)
.await
.with_context(
|| format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", build.config.git_provider, build.config.git_account),
)?;
let git_token =
build_git_token(&mut build, repo.as_mut()).await?;
let (registry_token, aws_ecr) =
validate_account_extract_registry_token_aws_ecr(&build).await?;
let registry_tokens =
validate_account_extract_registry_tokens(&build).await?;
let cancel = CancellationToken::new();
let cancel_clone = cancel.clone();
@@ -144,8 +204,7 @@ impl Resolve<RunBuild, (User, Update)> for State {
});
// GET BUILDER PERIPHERY
let (periphery, cleanup_data) = match get_builder_periphery(
let (periphery, cleanup_data) = match connect_builder_periphery(
build.name.clone(),
Some(build.config.version),
builder,
@@ -170,157 +229,93 @@ impl Resolve<RunBuild, (User, Update)> for State {
}
};
// CLONE REPO
// INTERPOLATE VARIABLES
let secret_replacers = if !build.config.skip_secret_interp {
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
let res = tokio::select! {
res = periphery
.request(api::git::CloneRepo {
args: (&build).into(),
git_token,
environment: Default::default(),
env_file_path: Default::default(),
skip_secret_interp: Default::default(),
}) => res,
_ = cancel.cancelled() => {
debug!("build cancelled during clone, cleaning up builder");
update.push_error_log("build cancelled", String::from("user cancelled build during repo clone"));
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
info!("builder cleaned up");
return handle_early_return(update, build.id, build.name, true).await
},
interpolator.interpolate_build(&mut build)?;
if let Some(repo) = repo.as_mut() {
interpolator.interpolate_repo(repo)?;
}
interpolator.push_logs(&mut update.logs);
interpolator.secret_replacers
} else {
Default::default()
};
let commit_message = match res {
Ok(res) => {
debug!("finished repo clone");
let res: RepoActionResponseV1_13 = res.into();
update.logs.extend(res.logs);
update.commit_hash =
res.commit_hash.unwrap_or_default().to_string();
res.commit_message.unwrap_or_default()
}
Err(e) => {
warn!("failed build at clone repo | {e:#}");
update.push_error_log(
"clone repo",
format_serror(&e.context("failed to clone repo").into()),
);
Default::default()
}
};
update_update(update.clone()).await?;
if all_logs_success(&update.logs) {
let secret_replacers = if !build.config.skip_secret_interp {
let core_config = core_config();
let variables = get_global_variables().await?;
// Interpolate variables / secrets into build args
let mut global_replacers = HashSet::new();
let mut secret_replacers = HashSet::new();
let mut secret_replacers_for_log = HashSet::new();
// Interpolate into build args
for arg in &mut build.config.build_args {
// first pass - global variables
let (res, more_replacers) = svi::interpolate_variables(
&arg.value,
&variables,
svi::Interpolator::DoubleBrackets,
false,
)
.context("failed to interpolate global variables")?;
global_replacers.extend(more_replacers);
// second pass - core secrets
let (res, more_replacers) = svi::interpolate_variables(
&res,
&core_config.secrets,
svi::Interpolator::DoubleBrackets,
false,
)
.context("failed to interpolate core secrets")?;
secret_replacers_for_log.extend(
more_replacers
.iter()
.map(|(_, variable)| variable.clone()),
);
secret_replacers.extend(more_replacers);
arg.value = res;
}
// Interpolate into secret args
for arg in &mut build.config.secret_args {
// first pass - global variables
let (res, more_replacers) = svi::interpolate_variables(
&arg.value,
&variables,
svi::Interpolator::DoubleBrackets,
false,
)
.context("failed to interpolate global variables")?;
global_replacers.extend(more_replacers);
// second pass - core secrets
let (res, more_replacers) = svi::interpolate_variables(
&res,
&core_config.secrets,
svi::Interpolator::DoubleBrackets,
false,
)
.context("failed to interpolate core secrets")?;
secret_replacers_for_log.extend(
more_replacers.into_iter().map(|(_, variable)| variable),
);
// Secret args don't need to be in replacers sent to periphery.
// The secret args don't end up in the command like build args do.
arg.value = res;
}
// Show which variables were interpolated
if !global_replacers.is_empty() {
update.push_simple_log(
"interpolate global variables",
global_replacers
.into_iter()
.map(|(value, variable)| format!("<span class=\"text-muted-foreground\">{variable} =></span> {value}"))
.collect::<Vec<_>>()
.join("\n"),
);
}
if !secret_replacers_for_log.is_empty() {
update.push_simple_log(
"interpolate core secrets",
secret_replacers_for_log
.into_iter()
.map(|variable| format!("<span class=\"text-muted-foreground\">replaced:</span> {variable}"))
.collect::<Vec<_>>()
.join("\n"),
);
}
secret_replacers
} else {
Default::default()
let commit_message = if !build.config.files_on_host
&& (!build.config.repo.is_empty()
|| !build.config.linked_repo.is_empty())
{
// PULL OR CLONE REPO
let res = tokio::select! {
res = periphery
.request(api::git::PullOrCloneRepo {
args: repo.as_ref().map(Into::into).unwrap_or((&build).into()),
git_token,
environment: Default::default(),
env_file_path: Default::default(),
on_clone: None,
on_pull: None,
skip_secret_interp: Default::default(),
replacers: Default::default(),
}) => res,
_ = cancel.cancelled() => {
debug!("Build cancelled during clone, cleaning up builder");
update.push_error_log("Build cancelled", String::from("user cancelled build during repo clone"));
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
info!("Builder cleaned up");
return handle_early_return(update, build.id, build.name, true).await
},
};
let commit_message = match res {
Ok(res) => {
debug!("finished repo clone");
update.logs.extend(res.res.logs);
update.commit_hash =
res.res.commit_hash.unwrap_or_default().to_string();
res.res.commit_message.unwrap_or_default()
}
Err(e) => {
warn!("Failed build at clone repo | {e:#}");
update.push_error_log(
"Clone Repo",
format_serror(&e.context("Failed to clone repo").into()),
);
Default::default()
}
};
update_update(update.clone()).await?;
Some(commit_message)
} else {
None
};
if all_logs_success(&update.logs) {
// RUN BUILD
let res = tokio::select! {
res = periphery
.request(api::build::Build {
build: build.clone(),
registry_token,
aws_ecr,
repo,
registry_tokens,
replacers: secret_replacers.into_iter().collect(),
// Push a commit hash tagged image
additional_tags: if update.commit_hash.is_empty() {
Default::default()
} else {
vec![update.commit_hash.clone()]
},
// To push a commit hash tagged image
commit_hash: optional_string(&update.commit_hash),
// Unused for now
additional_tags: Default::default(),
}) => res.context("failed at call to periphery to build"),
_ = cancel.cancelled() => {
info!("build cancelled during build, cleaning up builder");
update.push_error_log("build cancelled", String::from("user cancelled build during docker build"));
info!("Build cancelled during build, cleaning up builder");
update.push_error_log("Build cancelled", String::from("user cancelled build during docker build"));
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
return handle_early_return(update, build.id, build.name, true).await
@@ -335,7 +330,7 @@ impl Resolve<RunBuild, (User, Update)> for State {
Err(e) => {
warn!("error in build | {e:#}");
update.push_error_log(
"build",
"Build Error",
format_serror(&e.context("failed to build").into()),
)
}
@@ -344,7 +339,7 @@ impl Resolve<RunBuild, (User, Update)> for State {
update.finalize();
let db = db_client().await;
let db = db_client();
if update.success {
let _ = db
@@ -354,7 +349,7 @@ impl Resolve<RunBuild, (User, Update)> for State {
doc! { "$set": {
"config.version": to_bson(&build.config.version)
.context("failed at converting version to bson")?,
"info.last_built_at": monitor_timestamp(),
"info.last_built_at": komodo_timestamp(),
"info.built_hash": &update.commit_hash,
"info.built_message": commit_message
}},
@@ -365,6 +360,8 @@ impl Resolve<RunBuild, (User, Update)> for State {
// stop the cancel listening task from going forever
cancel.cancel();
// If building on temporary cloud server (AWS),
// this will terminate the server.
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
@@ -376,7 +373,7 @@ impl Resolve<RunBuild, (User, Update)> for State {
let _ = update_one_by_id(
&db.updates,
&update.id,
mungos::update::Update::Set(update_doc),
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
@@ -391,15 +388,14 @@ impl Resolve<RunBuild, (User, Update)> for State {
handle_post_build_redeploy(&build.id).await;
});
} else {
warn!("build unsuccessful, alerting...");
let target = update.target.clone();
let version = update.version;
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: monitor_timestamp(),
resolved_ts: Some(monitor_timestamp()),
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::BuildFailed {
@@ -412,17 +408,17 @@ impl Resolve<RunBuild, (User, Update)> for State {
});
}
Ok(update)
Ok(update.clone())
}
}
#[instrument(skip(update))]
#[instrument("HandleEarlyReturn", skip(update))]
async fn handle_early_return(
mut update: Update,
build_id: String,
build_name: String,
is_cancel: bool,
) -> anyhow::Result<Update> {
) -> serror::Result<Update> {
update.finalize();
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
@@ -430,9 +426,9 @@ async fn handle_early_return(
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().await.updates,
&db_client().updates,
&update.id,
mungos::update::Update::Set(update_doc),
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
@@ -440,15 +436,14 @@ async fn handle_early_return(
}
update_update(update.clone()).await?;
if !update.success && !is_cancel {
warn!("build unsuccessful, alerting...");
let target = update.target.clone();
let version = update.version;
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: monitor_timestamp(),
resolved_ts: Some(monitor_timestamp()),
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::BuildFailed {
@@ -460,17 +455,16 @@ async fn handle_early_return(
send_alerts(&[alert]).await
});
}
Ok(update)
Ok(update.clone())
}
#[instrument(skip_all)]
pub async fn validate_cancel_build(
request: &ExecuteRequest,
) -> anyhow::Result<()> {
if let ExecuteRequest::CancelBuild(req) = request {
let build = resource::get::<Build>(&req.build).await?;
let db = db_client().await;
let db = db_client();
let (latest_build, latest_cancel) = tokio::try_join!(
db.updates
@@ -510,17 +504,25 @@ pub async fn validate_cancel_build(
Ok(())
}
impl Resolve<CancelBuild, (User, Update)> for State {
#[instrument(name = "CancelBuild", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for CancelBuild {
#[instrument(
"CancelBuild",
skip(user, update),
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
build = self.build,
)
)]
async fn resolve(
&self,
CancelBuild { build }: CancelBuild,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Execute,
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let build = get_check_permissions::<Build>(
&self.build,
user,
PermissionLevel::Execute.into(),
)
.await?;
@@ -532,9 +534,11 @@ impl Resolve<CancelBuild, (User, Update)> for State {
.and_then(|s| s.get().ok().map(|s| s.building))
.unwrap_or_default()
{
return Err(anyhow!("Build is not building."));
return Err(anyhow!("Build is not building.").into());
}
let mut update = update.clone();
update.push_simple_log(
"cancel triggered",
"the build cancel has been triggered",
@@ -553,14 +557,16 @@ impl Resolve<CancelBuild, (User, Update)> for State {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
if let Err(e) = update_one_by_id(
&db_client().await.updates,
&db_client().updates,
&update_id,
doc! { "$set": { "status": "Complete" } },
None,
)
.await
{
warn!("failed to set CancelBuild Update status Complete after timeout | {e:#}")
warn!(
"failed to set CancelBuild Update status Complete after timeout | {e:#}"
)
}
});
@@ -568,10 +574,10 @@ impl Resolve<CancelBuild, (User, Update)> for State {
}
}
#[instrument]
#[instrument("PostBuildRedeploy")]
async fn handle_post_build_redeploy(build_id: &str) {
let Ok(redeploy_deployments) = find_collect(
&db_client().await.deployments,
&db_client().deployments,
doc! {
"config.image.params.build_id": build_id,
"config.redeploy_on_build": true
@@ -587,8 +593,9 @@ async fn handle_post_build_redeploy(build_id: &str) {
redeploy_deployments
.into_iter()
.map(|deployment| async move {
let state =
get_deployment_state(&deployment).await.unwrap_or_default();
let state = get_deployment_state(&deployment.id)
.await
.unwrap_or_default();
if state == DeploymentState::Running {
let req = super::ExecuteRequest::Deploy(Deploy {
deployment: deployment.id.clone(),
@@ -598,16 +605,17 @@ async fn handle_post_build_redeploy(build_id: &str) {
let user = auto_redeploy_user().to_owned();
let res = async {
let update = init_execution_update(&req, &user).await?;
State
.resolve(
Deploy {
deployment: deployment.id.clone(),
stop_signal: None,
stop_time: None,
},
(user, update),
)
.await
Deploy {
deployment: deployment.id.clone(),
stop_signal: None,
stop_time: None,
}
.resolve(&ExecuteArgs {
user,
update,
id: Uuid::new_v4(),
})
.await
}
.await;
Some((deployment.id.clone(), res))
@@ -621,71 +629,60 @@ async fn handle_post_build_redeploy(build_id: &str) {
continue;
};
if let Err(e) = res {
warn!("failed post build redeploy for deployment {id}: {e:#}");
warn!(
"failed post build redeploy for deployment {id}: {:#}",
e.error
);
}
}
}
/// This will make sure that a build with non-none image registry has an account attached,
/// and will check the core config for a token / aws ecr config matching requirements.
/// and will check the core config for a token matching requirements.
/// Otherwise it is left to periphery.
async fn validate_account_extract_registry_token_aws_ecr(
build: &Build,
) -> anyhow::Result<(Option<String>, Option<AwsEcrConfig>)> {
let (domain, account) = match &build.config.image_registry {
// Early return for None
ImageRegistry::None(_) => return Ok((None, None)),
// Early return for AwsEcr
ImageRegistry::AwsEcr(label) => {
// Note that aws ecr config still only lives in config file
let config = core_config()
.aws_ecr_registries
.iter()
.find(|reg| &reg.label == label);
let token = match config {
Some(AwsEcrConfigWithCredentials {
region,
access_key_id,
secret_access_key,
..
}) => {
let token = ecr::get_ecr_token(
region,
access_key_id,
secret_access_key,
)
.await
.context("failed to get aws ecr token")?;
ecr::maybe_create_repo(
&to_monitor_name(&build.name),
region.to_string(),
access_key_id,
secret_access_key,
)
.await
.context("failed to create aws ecr repo")?;
Some(token)
}
None => None,
};
return Ok((token, config.map(AwsEcrConfig::from)));
}
ImageRegistry::Standard(StandardRegistryConfig {
domain,
account,
..
}) => (domain.as_str(), account),
};
#[instrument("ValidateRegistryTokens")]
async fn validate_account_extract_registry_tokens(
Build {
config: BuildConfig { image_registry, .. },
..
}: &Build,
// Maps (domain, account) -> token
) -> serror::Result<Vec<(String, String, String)>> {
let mut res = HashMap::with_capacity(image_registry.capacity());
if account.is_empty() {
return Err(anyhow!(
"Must attach account to use registry provider {domain}"
));
for (domain, account) in image_registry
.iter()
.map(|r| (r.domain.as_str(), r.account.as_str()))
// This ensures uniqueness / prevents redundant logins
.collect::<HashSet<_>>()
{
if domain.is_empty() {
continue;
}
if account.is_empty() {
return Err(
anyhow!(
"Must attach account to use registry provider {domain}"
)
.into(),
);
}
let Some(registry_token) = registry_token(domain, account).await.with_context(
|| format!("Failed to get registry token in call to db. Stopping run. | {domain} | {account}"),
)? else {
continue;
};
res.insert(
(domain.to_string(), account.to_string()),
registry_token,
);
}
let registry_token = registry_token(domain, account).await.with_context(
|| format!("Failed to get registry token in call to db. Stopping run. | {domain} | {account}"),
)?;
Ok((registry_token, None))
Ok(
res
.into_iter()
.map(|((domain, account), token)| (domain, account, token))
.collect(),
)
}

View File

@@ -1,76 +1,119 @@
use anyhow::{anyhow, Context};
use std::sync::OnceLock;
use anyhow::{Context, anyhow};
use cache::TimeoutCache;
use formatting::format_serror;
use monitor_client::{
use interpolate::Interpolator;
use komodo_client::{
api::execute::*,
entities::{
build::{Build, ImageRegistry},
config::core::AwsEcrConfig,
Version,
build::{Build, ImageRegistryConfig},
deployment::{
extract_registry_domain, Deployment, DeploymentImage,
Deployment, DeploymentImage, extract_registry_domain,
},
get_image_name,
komodo_timestamp, optional_string,
permission::PermissionLevel,
server::{Server, ServerState},
server::Server,
update::{Log, Update},
user::User,
Version,
},
};
use periphery_client::api;
use resolver_api::Resolve;
use crate::{
cloud::aws::ecr,
config::core_config,
helpers::{
interpolate_variables_secrets_into_environment, periphery_client,
query::get_server_with_status, registry_token,
periphery_client,
query::{VariablesAndSecrets, get_variables_and_secrets},
registry_token,
update::update_update,
},
monitor::update_cache_for_server,
permission::get_check_permissions,
resource,
state::{action_states, State},
state::action_states,
};
use super::{ExecuteArgs, ExecuteRequest};
impl super::BatchExecute for BatchDeploy {
type Resource = Deployment;
fn single_request(deployment: String) -> ExecuteRequest {
ExecuteRequest::Deploy(Deploy {
deployment,
stop_signal: None,
stop_time: None,
})
}
}
impl Resolve<ExecuteArgs> for BatchDeploy {
#[instrument(
"BatchDeploy",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern,
)
)]
async fn resolve(
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchDeploy>(&self.pattern, user)
.await?,
)
}
}
#[instrument("SetupDeploy", skip_all)]
async fn setup_deployment_execution(
deployment: &str,
user: &User,
) -> anyhow::Result<(Deployment, Server)> {
let deployment = resource::get_check_permissions::<Deployment>(
let deployment = get_check_permissions::<Deployment>(
deployment,
user,
PermissionLevel::Execute,
PermissionLevel::Execute.into(),
)
.await?;
if deployment.config.server_id.is_empty() {
return Err(anyhow!("deployment has no server configured"));
return Err(anyhow!("Deployment has no Server configured"));
}
let (server, status) =
get_server_with_status(&deployment.config.server_id).await?;
if status != ServerState::Ok {
return Err(anyhow!(
"cannot send action when server is unreachable or disabled"
));
let server =
resource::get::<Server>(&deployment.config.server_id).await?;
if !server.config.enabled {
return Err(anyhow!("Attached Server is not enabled"));
}
Ok((deployment, server))
}
impl Resolve<Deploy, (User, Update)> for State {
#[instrument(name = "Deploy", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for Deploy {
#[instrument(
"Deploy",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
stop_signal = format!("{:?}", self.stop_signal),
stop_time = self.stop_time,
)
)]
async fn resolve(
&self,
Deploy {
deployment,
stop_signal,
stop_time,
}: Deploy,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (mut deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -83,36 +126,26 @@ impl Resolve<Deploy, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.deploying = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let (version, registry_token, aws_ecr) = match &deployment
.config
.image
{
// This block resolves the attached Build to an actual versioned image
let (version, registry_token) = match &deployment.config.image {
DeploymentImage::Build { build_id, version } => {
let build = resource::get::<Build>(build_id).await?;
let image_name = get_image_name(&build, |label| {
core_config()
.aws_ecr_registries
.iter()
.find(|reg| &reg.label == label)
.map(AwsEcrConfig::from)
})
.context("failed to create image name")?;
let image_names = build.get_image_names();
let image_name = image_names
.first()
.context("No image name could be created")
.context("Failed to create image name")?;
let version = if version.is_none() {
build.config.version
} else {
*version
};
// Remove ending patch if it is 0, this means use latest patch.
let version_str = if version.patch == 0 {
format!("{}.{}", version.major, version.minor)
} else {
version.to_string()
};
let version_str = version.to_string();
// Potentially add the build image_tag postfix
let version_str = if build.config.image_tag.is_empty() {
version_str
@@ -123,45 +156,33 @@ impl Resolve<Deploy, (User, Update)> for State {
deployment.config.image = DeploymentImage::Image {
image: format!("{image_name}:{version_str}"),
};
match build.config.image_registry {
ImageRegistry::None(_) => (version, None, None),
ImageRegistry::AwsEcr(label) => {
let config = core_config()
.aws_ecr_registries
.iter()
.find(|reg| reg.label == label)
.with_context(|| {
format!(
"did not find config for aws ecr registry {label}"
)
})?;
let token = ecr::get_ecr_token(
&config.region,
&config.access_key_id,
&config.secret_access_key,
)
.await
.context("failed to create aws ecr login token")?;
(version, Some(token), Some(AwsEcrConfig::from(config)))
}
ImageRegistry::Standard(params) => {
if deployment.config.image_registry_account.is_empty() {
deployment.config.image_registry_account =
params.account
}
let token = if !deployment
.config
.image_registry_account
.is_empty()
{
registry_token(&params.domain, &deployment.config.image_registry_account).await.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {} | {}", params.domain, deployment.config.image_registry_account),
)?
} else {
None
};
(version, token, None)
let first_registry = build
.config
.image_registry
.first()
.unwrap_or(ImageRegistryConfig::static_default());
if first_registry.domain.is_empty() {
(version, None)
} else {
let ImageRegistryConfig {
domain, account, ..
} = first_registry;
if deployment.config.image_registry_account.is_empty() {
deployment.config.image_registry_account =
account.to_string();
}
let token = if !deployment
.config
.image_registry_account
.is_empty()
{
registry_token(domain, &deployment.config.image_registry_account).await.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {domain} | {}", deployment.config.image_registry_account),
)?
} else {
None
};
(version, token)
}
}
DeploymentImage::Image { image } => {
@@ -177,16 +198,24 @@ impl Resolve<Deploy, (User, Update)> for State {
} else {
None
};
(Version::default(), token, None)
(Version::default(), token)
}
};
// interpolate variables / secrets, returning the sanitizing replacers to send to
// periphery so it may sanitize the final command for safe logging (avoids exposing secret values)
let secret_replacers = if !deployment.config.skip_secret_interp {
interpolate_variables_secrets_into_environment(
&mut deployment.config.environment,
&mut update,
)
.await?
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator
.interpolate_deployment(&mut deployment)?
.push_logs(&mut update.logs);
interpolator.secret_replacers
} else {
Default::default()
};
@@ -194,13 +223,13 @@ impl Resolve<Deploy, (User, Update)> for State {
update.version = version;
update_update(update.clone()).await?;
match periphery
match periphery_client(&server)
.await?
.request(api::container::Deploy {
deployment,
stop_signal,
stop_time,
stop_signal: self.stop_signal,
stop_time: self.stop_time,
registry_token,
aws_ecr,
replacers: secret_replacers.into_iter().collect(),
})
.await
@@ -208,15 +237,13 @@ impl Resolve<Deploy, (User, Update)> for State {
Ok(log) => update.logs.push(log),
Err(e) => {
update.push_error_log(
"deploy container",
format_serror(
&e.context("failed to deploy container").into(),
),
"Deploy Container",
format_serror(&e.into()),
);
}
};
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -225,15 +252,199 @@ impl Resolve<Deploy, (User, Update)> for State {
}
}
impl Resolve<StartContainer, (User, Update)> for State {
#[instrument(name = "StartContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
/// Wait this long after a pull to allow another pull through
const PULL_TIMEOUT: i64 = 5_000;
type ServerId = String;
type Image = String;
type PullCache = TimeoutCache<(ServerId, Image), Log>;
fn pull_cache() -> &'static PullCache {
static PULL_CACHE: OnceLock<PullCache> = OnceLock::new();
PULL_CACHE.get_or_init(Default::default)
}
#[instrument(
"PullDeploymentInner",
skip_all,
fields(
deployment = deployment.id,
server = server.id
)
)]
pub async fn pull_deployment_inner(
deployment: Deployment,
server: &Server,
) -> anyhow::Result<Log> {
let (image, account, token) = match deployment.config.image {
DeploymentImage::Build { build_id, version } => {
let build = resource::get::<Build>(&build_id).await?;
let image_names = build.get_image_names();
let image_name = image_names
.first()
.context("No image name could be created")
.context("Failed to create image name")?;
let version = if version.is_none() {
build.config.version.to_string()
} else {
version.to_string()
};
// Potentially add the build image_tag postfix
let version = if build.config.image_tag.is_empty() {
version
} else {
format!("{version}-{}", build.config.image_tag)
};
// replace image with corresponding build image.
let image = format!("{image_name}:{version}");
let first_registry = build
.config
.image_registry
.first()
.unwrap_or(ImageRegistryConfig::static_default());
if first_registry.domain.is_empty() {
(image, None, None)
} else {
let ImageRegistryConfig {
domain, account, ..
} = first_registry;
let account =
if deployment.config.image_registry_account.is_empty() {
account
} else {
&deployment.config.image_registry_account
};
let token = if !account.is_empty() {
registry_token(domain, account).await.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {domain} | {account}"),
)?
} else {
None
};
(image, optional_string(account), token)
}
}
DeploymentImage::Image { image } => {
let domain = extract_registry_domain(&image)?;
let token = if !deployment
.config
.image_registry_account
.is_empty()
{
registry_token(&domain, &deployment.config.image_registry_account).await.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {domain} | {}", deployment.config.image_registry_account),
)?
} else {
None
};
(
image,
optional_string(&deployment.config.image_registry_account),
token,
)
}
};
// Acquire the pull lock for this image on the server
let lock = pull_cache()
.get_lock((server.id.clone(), image.clone()))
.await;
// Lock the path lock, prevents simultaneous pulls by
// ensuring simultaneous pulls will wait for first to finish
// and checking cached results.
let mut locked = lock.lock().await;
// Early return from cache if lasted pulled with PULL_TIMEOUT
if locked.last_ts + PULL_TIMEOUT > komodo_timestamp() {
return locked.clone_res();
}
let res = async {
let log = match periphery_client(server)
.await?
.request(api::docker::PullImage {
name: image,
account,
token,
})
.await
{
Ok(log) => log,
Err(e) => Log::error("Pull image", format_serror(&e.into())),
};
update_cache_for_server(server, true).await;
anyhow::Ok(log)
}
.await;
// Set the cache with results. Any other calls waiting on the lock will
// then immediately also use this same result.
locked.set(&res, komodo_timestamp());
res
}
impl Resolve<ExecuteArgs> for PullDeployment {
#[instrument(
"PullDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
)
)]
async fn resolve(
&self,
StartContainer { deployment }: StartContainer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
.deployment
.get_or_insert_default(&deployment.id)
.await;
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.pulling = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let log = pull_deployment_inner(deployment, &server).await?;
update.logs.push(log);
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
impl Resolve<ExecuteArgs> for StartDeployment {
#[instrument(
"StartDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -246,14 +457,15 @@ impl Resolve<StartContainer, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.starting = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
let log = match periphery_client(&server)
.await?
.request(api::container::StartContainer {
name: deployment.name.clone(),
name: deployment.name,
})
.await
{
@@ -265,7 +477,7 @@ impl Resolve<StartContainer, (User, Update)> for State {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -273,15 +485,23 @@ impl Resolve<StartContainer, (User, Update)> for State {
}
}
impl Resolve<RestartContainer, (User, Update)> for State {
#[instrument(name = "RestartContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for RestartDeployment {
#[instrument(
"RestartDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
)
)]
async fn resolve(
&self,
RestartContainer { deployment }: RestartContainer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -294,14 +514,15 @@ impl Resolve<RestartContainer, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.restarting = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
let log = match periphery_client(&server)
.await?
.request(api::container::RestartContainer {
name: deployment.name.clone(),
name: deployment.name,
})
.await
{
@@ -315,7 +536,7 @@ impl Resolve<RestartContainer, (User, Update)> for State {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -323,15 +544,23 @@ impl Resolve<RestartContainer, (User, Update)> for State {
}
}
impl Resolve<PauseContainer, (User, Update)> for State {
#[instrument(name = "PauseContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for PauseDeployment {
#[instrument(
"PauseDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
)
)]
async fn resolve(
&self,
PauseContainer { deployment }: PauseContainer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -344,14 +573,15 @@ impl Resolve<PauseContainer, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.pausing = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
let log = match periphery_client(&server)
.await?
.request(api::container::PauseContainer {
name: deployment.name.clone(),
name: deployment.name,
})
.await
{
@@ -363,7 +593,7 @@ impl Resolve<PauseContainer, (User, Update)> for State {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -371,15 +601,23 @@ impl Resolve<PauseContainer, (User, Update)> for State {
}
}
impl Resolve<UnpauseContainer, (User, Update)> for State {
#[instrument(name = "UnpauseContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for UnpauseDeployment {
#[instrument(
"UnpauseDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
)
)]
async fn resolve(
&self,
UnpauseContainer { deployment }: UnpauseContainer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -392,14 +630,15 @@ impl Resolve<UnpauseContainer, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.unpausing = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
let log = match periphery_client(&server)
.await?
.request(api::container::UnpauseContainer {
name: deployment.name.clone(),
name: deployment.name,
})
.await
{
@@ -413,7 +652,7 @@ impl Resolve<UnpauseContainer, (User, Update)> for State {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -421,19 +660,25 @@ impl Resolve<UnpauseContainer, (User, Update)> for State {
}
}
impl Resolve<StopContainer, (User, Update)> for State {
#[instrument(name = "StopContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for StopDeployment {
#[instrument(
"StopDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
signal = format!("{:?}", self.signal),
time = self.time,
)
)]
async fn resolve(
&self,
StopContainer {
deployment,
signal,
time,
}: StopContainer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -446,18 +691,21 @@ impl Resolve<StopContainer, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.stopping = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
let log = match periphery_client(&server)
.await?
.request(api::container::StopContainer {
name: deployment.name.clone(),
signal: signal
name: deployment.name,
signal: self
.signal
.unwrap_or(deployment.config.termination_signal)
.into(),
time: time
time: self
.time
.unwrap_or(deployment.config.termination_timeout)
.into(),
})
@@ -471,7 +719,7 @@ impl Resolve<StopContainer, (User, Update)> for State {
};
update.logs.push(log);
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update.finalize();
update_update(update.clone()).await?;
@@ -479,19 +727,60 @@ impl Resolve<StopContainer, (User, Update)> for State {
}
}
impl Resolve<RemoveContainer, (User, Update)> for State {
#[instrument(name = "RemoveContainer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
async fn resolve(
&self,
RemoveContainer {
impl super::BatchExecute for BatchDestroyDeployment {
type Resource = Deployment;
fn single_request(deployment: String) -> ExecuteRequest {
ExecuteRequest::DestroyDeployment(DestroyDeployment {
deployment,
signal,
time,
}: RemoveContainer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
signal: None,
time: None,
})
}
}
impl Resolve<ExecuteArgs> for BatchDestroyDeployment {
#[instrument(
"BatchDestroyDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern,
)
)]
async fn resolve(
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchDestroyDeployment>(
&self.pattern,
user,
)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for DestroyDeployment {
#[instrument(
"DestroyDeployment",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
deployment = self.deployment,
signal = format!("{:?}", self.signal),
time = self.time,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let (deployment, server) =
setup_deployment_execution(&deployment, &user).await?;
setup_deployment_execution(&self.deployment, user).await?;
// get the action state for the deployment (or insert default).
let action_state = action_states()
@@ -502,20 +791,23 @@ impl Resolve<RemoveContainer, (User, Update)> for State {
// Will check to ensure deployment not already busy before updating, and return Err if so.
// The returned guard will set the action state back to default when dropped.
let _action_guard =
action_state.update(|state| state.removing = true)?;
action_state.update(|state| state.destroying = true)?;
let mut update = update.clone();
// Send update after setting action state, this way frontend gets correct state.
update_update(update.clone()).await?;
let periphery = periphery_client(&server)?;
let log = match periphery
let log = match periphery_client(&server)
.await?
.request(api::container::RemoveContainer {
name: deployment.name.clone(),
signal: signal
name: deployment.name,
signal: self
.signal
.unwrap_or(deployment.config.termination_signal)
.into(),
time: time
time: self
.time
.unwrap_or(deployment.config.termination_timeout)
.into(),
})
@@ -530,7 +822,7 @@ impl Resolve<RemoveContainer, (User, Update)> for State {
update.logs.push(log);
update.finalize();
update_cache_for_server(&server).await;
update_cache_for_server(&server, true).await;
update_update(update.clone()).await?;
Ok(update)

View File

@@ -0,0 +1,588 @@
use std::{fmt::Write as _, sync::OnceLock};
use anyhow::{Context, anyhow};
use command::run_komodo_command;
use database::{
bson::{Document, doc},
mungos::find::find_collect,
};
use formatting::{bold, format_serror};
use futures_util::{StreamExt, stream::FuturesOrdered};
use komodo_client::{
api::execute::{
BackupCoreDatabase, ClearRepoCache, GlobalAutoUpdate,
RotateAllServerKeys, RotateCoreKeys,
},
entities::{
deployment::DeploymentState, server::ServerState,
stack::StackState,
},
};
use periphery_client::api;
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use tokio::sync::Mutex;
use crate::{
api::execute::{
ExecuteArgs, pull_deployment_inner, pull_stack_inner,
},
config::{core_config, core_keys},
helpers::{periphery_client, update::update_update},
resource::rotate_server_keys,
state::{
db_client, deployment_status_cache, server_status_cache,
stack_status_cache,
},
};
/// Makes sure the method can only be called once at a time
fn clear_repo_cache_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(Default::default)
}
impl Resolve<ExecuteArgs> for ClearRepoCache {
#[instrument(
"ClearRepoCache",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::FORBIDDEN),
);
}
let _lock = clear_repo_cache_lock()
.try_lock()
.context("Clear already in progress...")?;
let mut update = update.clone();
let mut contents =
tokio::fs::read_dir(&core_config().repo_directory)
.await
.context("Failed to read repo cache directory")?;
loop {
let path = match contents
.next_entry()
.await
.context("Failed to read contents at path")
{
Ok(Some(contents)) => contents.path(),
Ok(None) => break,
Err(e) => {
update.push_error_log(
"Read Directory",
format_serror(&e.into()),
);
continue;
}
};
if path.is_dir() {
match tokio::fs::remove_dir_all(&path)
.await
.context("Failed to clear contents at path")
{
Ok(_) => {}
Err(e) => {
update.push_error_log(
"Clear Directory",
format_serror(&e.into()),
);
}
};
}
}
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
//
/// Makes sure the method can only be called once at a time
fn backup_database_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(Default::default)
}
impl Resolve<ExecuteArgs> for BackupCoreDatabase {
#[instrument(
"BackupCoreDatabase",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::FORBIDDEN),
);
}
let _lock = backup_database_lock()
.try_lock()
.context("Backup already in progress...")?;
let mut update = update.clone();
update_update(update.clone()).await?;
let res = run_komodo_command(
"Backup Core Database",
None,
"km database backup --yes",
)
.await;
update.logs.push(res);
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
//
/// Makes sure the method can only be called once at a time
fn global_update_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(Default::default)
}
impl Resolve<ExecuteArgs> for GlobalAutoUpdate {
#[instrument(
"GlobalAutoUpdate",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::FORBIDDEN),
);
}
let _lock = global_update_lock()
.try_lock()
.context("Global update already in progress...")?;
let mut update = update.clone();
update_update(update.clone()).await?;
// This is all done in sequence because there is no rush,
// the pulls / deploys happen spaced out to ease the load on system.
let servers = find_collect(&db_client().servers, None, None)
.await
.context("Failed to query for servers from database")?;
let query = doc! {
"$or": [
{ "config.poll_for_updates": true },
{ "config.auto_update": true }
]
};
let (stacks, repos) = tokio::try_join!(
find_collect(&db_client().stacks, query.clone(), None),
find_collect(&db_client().repos, None, None)
)
.context("Failed to query for resources from database")?;
let server_status_cache = server_status_cache();
let stack_status_cache = stack_status_cache();
// Will be edited later at update.logs[0]
update.push_simple_log("Auto Pull", String::new());
for stack in stacks {
let Some(status) = stack_status_cache.get(&stack.id).await
else {
continue;
};
// Only pull running stacks.
if !matches!(status.curr.state, StackState::Running) {
continue;
}
if let Some(server) =
servers.iter().find(|s| s.id == stack.config.server_id)
// This check is probably redundant along with running check
// but shouldn't hurt
&& server_status_cache
.get(&server.id)
.await
.map(|s| matches!(s.state, ServerState::Ok))
.unwrap_or_default()
{
let name = stack.name.clone();
let repo = if stack.config.linked_repo.is_empty() {
None
} else {
let Some(repo) =
repos.iter().find(|r| r.id == stack.config.linked_repo)
else {
update.push_error_log(
&format!("Pull Stack {name}"),
format!(
"Did not find any Repo matching {}",
stack.config.linked_repo
),
);
continue;
};
Some(repo.clone())
};
if let Err(e) =
pull_stack_inner(stack, Vec::new(), server, repo, None)
.await
{
update.push_error_log(
&format!("Pull Stack {name}"),
format_serror(&e.into()),
);
} else {
if !update.logs[0].stdout.is_empty() {
update.logs[0].stdout.push('\n');
}
update.logs[0]
.stdout
.push_str(&format!("Pulled Stack {}", bold(name)));
}
}
}
let deployment_status_cache = deployment_status_cache();
let deployments =
find_collect(&db_client().deployments, query, None)
.await
.context("Failed to query for deployments from database")?;
for deployment in deployments {
let Some(status) =
deployment_status_cache.get(&deployment.id).await
else {
continue;
};
// Only pull running deployments.
if !matches!(status.curr.state, DeploymentState::Running) {
continue;
}
if let Some(server) =
servers.iter().find(|s| s.id == deployment.config.server_id)
// This check is probably redundant along with running check
// but shouldn't hurt
&& server_status_cache
.get(&server.id)
.await
.map(|s| matches!(s.state, ServerState::Ok))
.unwrap_or_default()
{
let name = deployment.name.clone();
if let Err(e) =
pull_deployment_inner(deployment, server).await
{
update.push_error_log(
&format!("Pull Deployment {name}"),
format_serror(&e.into()),
);
} else {
if !update.logs[0].stdout.is_empty() {
update.logs[0].stdout.push('\n');
}
update.logs[0].stdout.push_str(&format!(
"Pulled Deployment {}",
bold(name)
));
}
}
}
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
//
/// Makes sure the method can only be called once at a time
fn global_rotate_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(Default::default)
}
impl Resolve<ExecuteArgs> for RotateAllServerKeys {
#[instrument(
"RotateAllServerKeys",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::FORBIDDEN),
);
}
let _lock = global_rotate_lock()
.try_lock()
.context("Key rotation already in progress...")?;
let mut update = update.clone();
update_update(update.clone()).await?;
let mut servers = db_client()
.servers
.find(Document::new())
.await
.context("Failed to query servers from database")?;
let server_status_cache = server_status_cache();
let mut log = String::new();
while let Some(server) = servers.next().await {
let server = match server {
Ok(server) => server,
Err(e) => {
warn!("Failed to parse Server | {e:#}");
continue;
}
};
if !server.config.auto_rotate_keys {
let _ = write!(
&mut log,
"\nSkipping {}: Key Rotation Disabled ⚙️",
bold(&server.name)
);
continue;
}
let Some(status) = server_status_cache.get(&server.id).await
else {
let _ = write!(
&mut log,
"\nSkipping {}: No Status ⚠️",
bold(&server.name)
);
continue;
};
match status.state {
ServerState::Disabled => {
let _ = write!(
&mut log,
"\nSkipping {}: Server Disabled ⚙️",
bold(&server.name)
);
continue;
}
ServerState::NotOk => {
let _ = write!(
&mut log,
"\nSkipping {}: Server Not Ok ⚠️",
bold(&server.name)
);
continue;
}
_ => {}
}
match rotate_server_keys(&server).await {
Ok(_) => {
let _ = write!(
&mut log,
"\nRotated keys for {} ✅",
bold(&server.name)
);
}
Err(e) => {
update.push_error_log(
"Key Rotation Failure",
format_serror(
&e.context(format!(
"Failed to rotate {} keys",
bold(&server.name)
))
.into(),
),
);
}
}
}
update.push_simple_log("Rotate Server Keys", log);
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}
impl Resolve<ExecuteArgs> for RotateCoreKeys {
#[instrument(
"RotateCoreKeys",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
force = self.force,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> Result<Self::Response, Self::Error> {
if !user.admin {
return Err(
anyhow!("This method is admin only.")
.status_code(StatusCode::FORBIDDEN),
);
}
let _lock = global_rotate_lock()
.try_lock()
.context("Key rotation already in progress...")?;
let mut update = update.clone();
update_update(update.clone()).await?;
let core_keys = core_keys();
if !core_keys.rotatable() {
return Err(anyhow!("Core `private_key` must be pointing to file, for example 'file:/config/keys/core.key'").into());
};
let server_status_cache = server_status_cache();
let servers =
find_collect(&db_client().servers, Document::new(), None)
.await
.context("Failed to query servers from database")?
.into_iter()
.map(|server| async move {
let state = server_status_cache
.get(&server.id)
.await
.map(|s| s.state)
.unwrap_or(ServerState::NotOk);
(server, state)
})
.collect::<FuturesOrdered<_>>()
.collect::<Vec<_>>()
.await;
if !self.force
&& let Some((server, _)) = servers
.iter()
.find(|(_, state)| matches!(state, ServerState::NotOk))
{
return Err(
anyhow!("Server {} is NotOk, stopping key rotation. Pass `force: true` to continue anyways.", server.name).into(),
);
}
let public_key = core_keys.rotate().await?.into_inner();
info!("New Public Key: {public_key}");
let mut log = format!("New Public Key: {public_key}\n");
for (server, state) in servers {
match state {
ServerState::Disabled => {
let _ = write!(
&mut log,
"\nSkipping {}: Server Disabled ⚙️",
bold(&server.name)
);
continue;
}
ServerState::NotOk => {
// Shouldn't be reached unless 'force: true'
let _ = write!(
&mut log,
"\nSkipping {}: Server Not Ok ⚠️",
bold(&server.name)
);
continue;
}
_ => {}
}
let periphery = periphery_client(&server).await?;
let res = periphery
.request(api::keys::RotateCorePublicKey {
public_key: public_key.clone(),
})
.await;
match res {
Ok(_) => {
let _ = write!(
&mut log,
"\nRotated key for {} ✅",
bold(&server.name)
);
}
Err(e) => {
update.push_error_log(
"Key Rotation Failure",
format_serror(
&e.context(format!(
"Failed to rotate for {}. The new Core public key will have to be added manually.",
bold(&server.name)
))
.into(),
),
);
}
}
}
update.push_simple_log("Rotate Core Keys", log);
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}

View File

@@ -1,171 +1,372 @@
use std::time::Instant;
use std::pin::Pin;
use anyhow::{anyhow, Context};
use axum::{middleware, routing::post, Extension, Router};
use anyhow::Context;
use axum::{
Extension, Router, extract::Path, middleware, routing::post,
};
use axum_extra::{TypedHeader, headers::ContentType};
use database::mungos::by_id::find_one_by_id;
use derive_variants::{EnumVariants, ExtractVariant};
use formatting::format_serror;
use monitor_client::{
use futures_util::{
StreamExt as _, future::join_all, stream::FuturesUnordered,
};
use komodo_client::{
api::execute::*,
entities::{
Operation,
permission::PermissionLevel,
update::{Log, Update},
user::User,
},
};
use mungos::by_id::find_one_by_id;
use resolver_api::{derive::Resolver, Resolver};
use resolver_api::Resolve;
use response::JsonString;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use strum::Display;
use typeshare::typeshare;
use uuid::Uuid;
use crate::{
auth::auth_request,
helpers::update::{init_execution_update, update_update},
state::{db_client, State},
permission::get_check_permissions,
resource::{KomodoResource, list_full_for_user_using_pattern},
state::db_client,
};
mod action;
mod alerter;
mod build;
mod deployment;
mod maintenance;
mod procedure;
mod repo;
mod server;
mod server_template;
mod stack;
mod sync;
use super::Variant;
pub use {
deployment::pull_deployment_inner, stack::pull_stack_inner,
};
pub struct ExecuteArgs {
/// The execution id.
/// Unique for every /execute call.
pub id: Uuid,
pub user: User,
pub update: Update,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Resolver)]
#[resolver_target(State)]
#[resolver_args((User, Update))]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
)]
#[variant_derive(Debug, Display)]
#[args(ExecuteArgs)]
#[response(JsonString)]
#[error(serror::Error)]
#[serde(tag = "type", content = "params")]
pub enum ExecuteRequest {
// ==== SERVER ====
StopAllContainers(StopAllContainers),
PruneContainers(PruneContainers),
PruneImages(PruneImages),
PruneNetworks(PruneNetworks),
// ==== DEPLOYMENT ====
Deploy(Deploy),
StartContainer(StartContainer),
RestartContainer(RestartContainer),
PauseContainer(PauseContainer),
UnpauseContainer(UnpauseContainer),
StopContainer(StopContainer),
RemoveContainer(RemoveContainer),
DestroyContainer(DestroyContainer),
StartAllContainers(StartAllContainers),
RestartAllContainers(RestartAllContainers),
PauseAllContainers(PauseAllContainers),
UnpauseAllContainers(UnpauseAllContainers),
StopAllContainers(StopAllContainers),
PruneContainers(PruneContainers),
DeleteNetwork(DeleteNetwork),
PruneNetworks(PruneNetworks),
DeleteImage(DeleteImage),
PruneImages(PruneImages),
DeleteVolume(DeleteVolume),
PruneVolumes(PruneVolumes),
PruneDockerBuilders(PruneDockerBuilders),
PruneBuildx(PruneBuildx),
PruneSystem(PruneSystem),
// ==== STACK ====
DeployStack(DeployStack),
BatchDeployStack(BatchDeployStack),
DeployStackIfChanged(DeployStackIfChanged),
BatchDeployStackIfChanged(BatchDeployStackIfChanged),
PullStack(PullStack),
BatchPullStack(BatchPullStack),
StartStack(StartStack),
RestartStack(RestartStack),
StopStack(StopStack),
PauseStack(PauseStack),
UnpauseStack(UnpauseStack),
DestroyStack(DestroyStack),
BatchDestroyStack(BatchDestroyStack),
RunStackService(RunStackService),
// ==== DEPLOYMENT ====
Deploy(Deploy),
BatchDeploy(BatchDeploy),
PullDeployment(PullDeployment),
StartDeployment(StartDeployment),
RestartDeployment(RestartDeployment),
PauseDeployment(PauseDeployment),
UnpauseDeployment(UnpauseDeployment),
StopDeployment(StopDeployment),
DestroyDeployment(DestroyDeployment),
BatchDestroyDeployment(BatchDestroyDeployment),
// ==== BUILD ====
RunBuild(RunBuild),
BatchRunBuild(BatchRunBuild),
CancelBuild(CancelBuild),
// ==== REPO ====
CloneRepo(CloneRepo),
BatchCloneRepo(BatchCloneRepo),
PullRepo(PullRepo),
BatchPullRepo(BatchPullRepo),
BuildRepo(BuildRepo),
BatchBuildRepo(BatchBuildRepo),
CancelRepoBuild(CancelRepoBuild),
// ==== PROCEDURE ====
RunProcedure(RunProcedure),
BatchRunProcedure(BatchRunProcedure),
// ==== SERVER TEMPLATE ====
LaunchServer(LaunchServer),
// ==== ACTION ====
RunAction(RunAction),
BatchRunAction(BatchRunAction),
// ==== ALERTER ====
TestAlerter(TestAlerter),
SendAlert(SendAlert),
// ==== SYNC ====
RunSync(RunSync),
// ==== MAINTENANCE ====
ClearRepoCache(ClearRepoCache),
BackupCoreDatabase(BackupCoreDatabase),
GlobalAutoUpdate(GlobalAutoUpdate),
RotateAllServerKeys(RotateAllServerKeys),
RotateCoreKeys(RotateCoreKeys),
}
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<(TypedHeader<ContentType>, String)> {
let req: ExecuteRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
async fn handler(
Extension(user): Extension<User>,
Json(request): Json<ExecuteRequest>,
) -> serror::Result<Json<Update>> {
let req_id = Uuid::new_v4();
// need to validate no cancel is active before any update is created.
build::validate_cancel_build(&request).await?;
let update = init_execution_update(&request, &user).await?;
let handle =
tokio::spawn(task(req_id, request, user, update.clone()));
tokio::spawn({
let update_id = update.id.clone();
async move {
let log = match handle.await {
Ok(Err(e)) => {
warn!("/execute request {req_id} task error: {e:#}",);
Log::error("task error", format_serror(&e.into()))
}
Err(e) => {
warn!("/execute request {req_id} spawn error: {e:?}",);
Log::error("spawn error", format!("{e:#?}"))
}
_ => return,
};
let res = async {
let mut update =
find_one_by_id(&db_client().await.updates, &update_id)
.await
.context("failed to query to db")?
.context("no update exists with given id")?;
update.logs.push(log);
update.finalize();
update_update(update).await
}
.await;
if let Err(e) = res {
warn!("failed to update update with task error log | {e:#}");
}
}
});
Ok(Json(update))
) -> serror::Result<(TypedHeader<ContentType>, String)> {
let res = match inner_handler(request, user).await? {
ExecutionResult::Single(update) => serde_json::to_string(&update)
.context("Failed to serialize Update")?,
ExecutionResult::Batch(res) => res,
};
Ok((TypedHeader(ContentType::json()), res))
}
#[typeshare(serialized_as = "Update")]
type BoxUpdate = Box<Update>;
pub enum ExecutionResult {
Single(BoxUpdate),
/// The batch contents will be pre serialized here
Batch(String),
}
pub fn inner_handler(
request: ExecuteRequest,
user: User,
) -> Pin<
Box<
dyn std::future::Future<Output = anyhow::Result<ExecutionResult>>
+ Send,
>,
> {
Box::pin(async move {
let task_id = Uuid::new_v4();
// Need to validate no cancel is active before any update is created.
// This ensures no double update created if Cancel is called more than once for the same request.
build::validate_cancel_build(&request).await?;
repo::validate_cancel_repo_build(&request).await?;
let update = init_execution_update(&request, &user).await?;
// This will be the case for the Batch exections,
// they don't have their own updates.
// The batch calls also call "inner_handler" themselves,
// and in their case will spawn tasks, so that isn't necessary
// here either.
if update.operation == Operation::None {
return Ok(ExecutionResult::Batch(
task(task_id, request, user, update).await?,
));
}
// Spawn a task for the execution which continues
// running after this method returns.
let handle =
tokio::spawn(task(task_id, request, user, update.clone()));
// Spawns another task to monitor the first for failures,
// and add the log to Update about it (which primary task can't do because it errored out)
tokio::spawn({
let update_id = update.id.clone();
async move {
let log = match handle.await {
Ok(Err(e)) => {
warn!("/execute request {task_id} task error: {e:#}",);
Log::error("Task Error", format_serror(&e.into()))
}
Err(e) => {
warn!("/execute request {task_id} spawn error: {e:?}",);
Log::error("Spawn Error", format!("{e:#?}"))
}
_ => return,
};
let res = async {
// Nothing to do if update was never actually created,
// which is the case when the id is empty.
if update_id.is_empty() {
return Ok(());
}
let mut update =
find_one_by_id(&db_client().updates, &update_id)
.await
.context("failed to query to db")?
.context("no update exists with given id")?;
update.logs.push(log);
update.finalize();
update_update(update).await
}
.await;
if let Err(e) = res {
warn!(
"failed to update update with task error log | {e:#}"
);
}
}
});
Ok(ExecutionResult::Single(update.into()))
})
}
#[instrument(name = "ExecuteRequest", skip(user, update), fields(user_id = user.id, update_id = update.id))]
async fn task(
req_id: Uuid,
id: Uuid,
request: ExecuteRequest,
user: User,
update: Update,
) -> anyhow::Result<String> {
info!(
"/execute request {req_id} | user: {} ({})",
user.username, user.id
);
let timer = Instant::now();
let variant = request.extract_variant();
let res = State
.resolve_request(request, (user, update))
.await
.map_err(|e| match e {
resolver_api::Error::Serialization(e) => {
anyhow!("{e:?}").context("response serialization error")
}
resolver_api::Error::Inner(e) => e,
});
info!(
"/execute request {id} | {variant} | user: {}",
user.username
);
let res =
match request.resolve(&ExecuteArgs { user, update, id }).await {
Err(e) => Err(e.error),
Ok(JsonString::Err(e)) => Err(
anyhow::Error::from(e)
.context("failed to serialize response"),
),
Ok(JsonString::Ok(res)) => Ok(res),
};
if let Err(e) = &res {
warn!("/execute request {req_id} error: {e:#}");
warn!("/execute request {id} error: {e:#}");
}
let elapsed = timer.elapsed();
debug!("/execute request {req_id} | resolve time: {elapsed:?}");
res
}
trait BatchExecute {
type Resource: KomodoResource;
fn single_request(name: String) -> ExecuteRequest;
}
#[instrument("BatchExecute", skip(user))]
async fn batch_execute<E: BatchExecute>(
pattern: &str,
user: &User,
) -> anyhow::Result<BatchExecutionResponse> {
let resources = list_full_for_user_using_pattern::<E::Resource>(
pattern,
Default::default(),
user,
&[],
)
.await?;
let resources = if user.admin {
resources
} else {
// Only keep resources with execute permissions
resources
.into_iter()
.map(|resource| async move {
get_check_permissions::<E::Resource>(
&resource.id,
user,
PermissionLevel::Execute.into(),
)
.await
})
.collect::<FuturesUnordered<_>>()
.collect::<Vec<_>>()
.await
.into_iter()
.flatten()
.collect()
};
let futures = resources.into_iter().map(|resource| {
let user = user.clone();
async move {
inner_handler(E::single_request(resource.name.clone()), user)
.await
.map(|r| {
let ExecutionResult::Single(update) = r else {
unreachable!()
};
update
})
.map_err(|e| BatchExecutionResponseItemErr {
name: resource.name,
error: e.into(),
})
.into()
}
});
Ok(join_all(futures).await)
}

View File

@@ -1,31 +1,78 @@
use std::pin::Pin;
use formatting::{bold, colored, format_serror, muted, Color};
use monitor_client::{
api::execute::RunProcedure,
use database::mungos::{
by_id::update_one_by_id, mongodb::bson::to_document,
};
use formatting::{Color, bold, colored, format_serror, muted};
use komodo_client::{
api::execute::{
BatchExecutionResponse, BatchRunProcedure, RunProcedure,
},
entities::{
permission::PermissionLevel, procedure::Procedure,
update::Update, user::User,
alert::{Alert, AlertData, SeverityLevel},
komodo_timestamp,
permission::PermissionLevel,
procedure::Procedure,
update::Update,
user::User,
},
};
use mungos::{by_id::update_one_by_id, mongodb::bson::to_document};
use resolver_api::Resolve;
use tokio::sync::Mutex;
use crate::{
alert::send_alerts,
helpers::{procedure::execute_procedure, update::update_update},
resource::{self, refresh_procedure_state_cache},
state::{action_states, db_client, State},
permission::get_check_permissions,
resource::refresh_procedure_state_cache,
state::{action_states, db_client},
};
impl Resolve<RunProcedure, (User, Update)> for State {
#[instrument(name = "RunProcedure", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
use super::{ExecuteArgs, ExecuteRequest};
impl super::BatchExecute for BatchRunProcedure {
type Resource = Procedure;
fn single_request(procedure: String) -> ExecuteRequest {
ExecuteRequest::RunProcedure(RunProcedure { procedure })
}
}
impl Resolve<ExecuteArgs> for BatchRunProcedure {
#[instrument(
"BatchRunProcedure",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
&self,
RunProcedure { procedure }: RunProcedure,
(user, update): (User, Update),
) -> anyhow::Result<Update> {
resolve_inner(procedure, user, update).await
self,
ExecuteArgs { user, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchRunProcedure>(&self.pattern, user)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for RunProcedure {
#[instrument(
"RunProcedure",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
procedure = self.procedure,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
Ok(
resolve_inner(self.procedure, user.clone(), update.clone())
.await?,
)
}
}
@@ -39,10 +86,10 @@ fn resolve_inner(
>,
> {
Box::pin(async move {
let procedure = resource::get_check_permissions::<Procedure>(
let procedure = get_check_permissions::<Procedure>(
&procedure,
&user,
PermissionLevel::Execute,
PermissionLevel::Execute.into(),
)
.await?;
@@ -50,7 +97,7 @@ fn resolve_inner(
// assumes first log is already created
// and will panic otherwise.
update.push_simple_log(
"execute_procedure",
"Execute procedure",
format!(
"{}: executing procedure '{}'",
muted("INFO"),
@@ -80,9 +127,9 @@ fn resolve_inner(
match res {
Ok(_) => {
update.push_simple_log(
"execution ok",
"Execution ok",
format!(
"{}: the procedure has {} with no errors",
"{}: The procedure has {} with no errors",
muted("INFO"),
colored("completed", Color::Green)
),
@@ -100,9 +147,9 @@ fn resolve_inner(
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().await.updates,
&db_client().updates,
&update.id,
mungos::update::Update::Set(update_doc),
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
@@ -111,6 +158,25 @@ fn resolve_inner(
update_update(update.clone()).await?;
if !update.success && procedure.config.failure_alert {
let target = update.target.clone();
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::ProcedureFailed {
id: procedure.id,
name: procedure.name,
},
};
send_alerts(&[alert]).await
});
}
Ok(update)
})
}

View File

@@ -1,56 +1,95 @@
use std::{future::IntoFuture, time::Duration};
use std::{collections::HashSet, future::IntoFuture, time::Duration};
use anyhow::{anyhow, Context};
use formatting::format_serror;
use monitor_client::{
api::execute::*,
entities::{
alert::{Alert, AlertData},
builder::{Builder, BuilderConfig},
monitor_timestamp, optional_string,
permission::PermissionLevel,
repo::Repo,
server::{stats::SeverityLevel, Server},
update::{Log, Update},
user::User,
},
};
use mungos::{
use anyhow::{Context, anyhow};
use database::mungos::{
by_id::update_one_by_id,
mongodb::{
bson::{doc, to_document},
options::FindOneOptions,
},
};
use periphery_client::api::{self, git::RepoActionResponseV1_13};
use formatting::format_serror;
use interpolate::Interpolator;
use komodo_client::{
api::{execute::*, write::RefreshRepoCache},
entities::{
alert::{Alert, AlertData, SeverityLevel},
builder::{Builder, BuilderConfig},
komodo_timestamp,
permission::PermissionLevel,
repo::Repo,
server::Server,
update::{Log, Update},
},
};
use periphery_client::api;
use resolver_api::Resolve;
use tokio_util::sync::CancellationToken;
use crate::{
alert::send_alerts,
api::write::WriteArgs,
helpers::{
alert::send_alerts,
builder::{cleanup_builder_instance, get_builder_periphery},
builder::{cleanup_builder_instance, connect_builder_periphery},
channel::repo_cancel_channel,
git_token, periphery_client,
query::{VariablesAndSecrets, get_variables_and_secrets},
update::update_update,
},
permission::get_check_permissions,
resource::{self, refresh_repo_state_cache},
state::{action_states, db_client, State},
state::{action_states, db_client},
};
use super::ExecuteRequest;
use super::{ExecuteArgs, ExecuteRequest};
impl Resolve<CloneRepo, (User, Update)> for State {
#[instrument(name = "CloneRepo", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl super::BatchExecute for BatchCloneRepo {
type Resource = Repo;
fn single_request(repo: String) -> ExecuteRequest {
ExecuteRequest::CloneRepo(CloneRepo { repo })
}
}
impl Resolve<ExecuteArgs> for BatchCloneRepo {
#[instrument(
"BatchCloneRepo",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern,
)
)]
async fn resolve(
&self,
CloneRepo { repo }: CloneRepo,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let mut repo = resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Execute,
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchCloneRepo>(&self.pattern, user)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for CloneRepo {
#[instrument(
"CloneRepo",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
repo = self.repo,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let mut repo = get_check_permissions::<Repo>(
&self.repo,
user,
PermissionLevel::Execute.into(),
)
.await?;
@@ -63,8 +102,13 @@ impl Resolve<CloneRepo, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.cloning = true)?;
let mut update = update.clone();
update_update(update.clone()).await?;
if repo.config.server_id.is_empty() {
return Err(anyhow!("repo has no server attached").into());
}
let git_token = git_token(
&repo.config.git_provider,
&repo.config.git_account,
@@ -75,33 +119,34 @@ impl Resolve<CloneRepo, (User, Update)> for State {
|| format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account),
)?;
if repo.config.server_id.is_empty() {
return Err(anyhow!("repo has no server attached"));
}
let server =
resource::get::<Server>(&repo.config.server_id).await?;
let periphery = periphery_client(&server)?;
let periphery = periphery_client(&server).await?;
// interpolate variables / secrets, returning the sanitizing replacers to send to
// periphery so it may sanitize the final command for safe logging (avoids exposing secret values)
let secret_replacers =
interpolate(&mut repo, &mut update).await?;
let logs = match periphery
.request(api::git::CloneRepo {
args: (&repo).into(),
git_token,
environment: repo.config.environment,
environment: repo.config.env_vars()?,
env_file_path: repo.config.env_file_path,
on_clone: repo.config.on_clone.into(),
on_pull: repo.config.on_pull.into(),
skip_secret_interp: repo.config.skip_secret_interp,
replacers: secret_replacers.into_iter().collect(),
})
.await
{
Ok(res) => {
let res: RepoActionResponseV1_13 = res.into();
res.logs
}
Ok(res) => res.res.logs,
Err(e) => {
vec![Log::error(
"clone repo",
format_serror(&e.context("failed to clone repo").into()),
"Clone Repo",
format_serror(&e.context("Failed to clone repo").into()),
)]
}
};
@@ -113,21 +158,69 @@ impl Resolve<CloneRepo, (User, Update)> for State {
update_last_pulled_time(&repo.name).await;
}
handle_server_update_return(update).await
if let Err(e) = (RefreshRepoCache { repo: repo.id })
.resolve(&WriteArgs { user: user.clone() })
.await
.map_err(|e| e.error)
.context("Failed to refresh repo cache")
{
update.push_error_log(
"Refresh Repo cache",
format_serror(&e.into()),
);
};
handle_repo_update_return(update).await
}
}
impl Resolve<PullRepo, (User, Update)> for State {
#[instrument(name = "PullRepo", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl super::BatchExecute for BatchPullRepo {
type Resource = Repo;
fn single_request(repo: String) -> ExecuteRequest {
ExecuteRequest::PullRepo(PullRepo { repo })
}
}
impl Resolve<ExecuteArgs> for BatchPullRepo {
#[instrument(
"BatchPullRepo",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern
)
)]
async fn resolve(
&self,
PullRepo { repo }: PullRepo,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let repo = resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Execute,
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchPullRepo>(&self.pattern, user)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for PullRepo {
#[instrument(
"PullRepo",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
repo = self.repo,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let mut repo = get_check_permissions::<Repo>(
&self.repo,
user,
PermissionLevel::Execute.into(),
)
.await?;
@@ -140,33 +233,49 @@ impl Resolve<PullRepo, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.pulling = true)?;
let mut update = update.clone();
update_update(update.clone()).await?;
if repo.config.server_id.is_empty() {
return Err(anyhow!("repo has no server attached"));
return Err(anyhow!("repo has no server attached").into());
}
let git_token = git_token(
&repo.config.git_provider,
&repo.config.git_account,
|https| repo.config.git_https = https,
)
.await
.with_context(
|| format!("Failed to get git token in call to db. This is a database error, not a token exisitence error. Stopping run. | {} | {}", repo.config.git_provider, repo.config.git_account),
)?;
let server =
resource::get::<Server>(&repo.config.server_id).await?;
let periphery = periphery_client(&server)?;
let periphery = periphery_client(&server).await?;
// interpolate variables / secrets, returning the sanitizing replacers to send to
// periphery so it may sanitize the final command for safe logging (avoids exposing secret values)
let secret_replacers =
interpolate(&mut repo, &mut update).await?;
let logs = match periphery
.request(api::git::PullRepo {
name: repo.name.clone(),
branch: optional_string(&repo.config.branch),
commit: optional_string(&repo.config.commit),
on_pull: repo.config.on_pull.into_option(),
environment: repo.config.environment,
args: (&repo).into(),
git_token,
environment: repo.config.env_vars()?,
env_file_path: repo.config.env_file_path,
on_pull: repo.config.on_pull.into(),
skip_secret_interp: repo.config.skip_secret_interp,
replacers: secret_replacers.into_iter().collect(),
})
.await
{
Ok(res) => {
let res: RepoActionResponseV1_13 = res.into();
update.commit_hash = res.commit_hash.unwrap_or_default();
res.logs
update.commit_hash = res.res.commit_hash.unwrap_or_default();
res.res.logs
}
Err(e) => {
vec![Log::error(
@@ -184,23 +293,39 @@ impl Resolve<PullRepo, (User, Update)> for State {
update_last_pulled_time(&repo.name).await;
}
handle_server_update_return(update).await
if let Err(e) = (RefreshRepoCache { repo: repo.id })
.resolve(&WriteArgs { user: user.clone() })
.await
.map_err(|e| e.error)
.context("Failed to refresh repo cache")
{
update.push_error_log(
"Refresh Repo cache",
format_serror(&e.into()),
);
};
handle_repo_update_return(update).await
}
}
#[instrument(skip_all, fields(update_id = update.id))]
async fn handle_server_update_return(
#[instrument(
"HandleRepoEarlyReturn",
skip_all,
fields(update_id = update.id)
)]
async fn handle_repo_update_return(
update: Update,
) -> anyhow::Result<Update> {
) -> serror::Result<Update> {
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
// The Err case of to_document should be unreachable,
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().await.updates,
&db_client().updates,
&update.id,
mungos::update::Update::Set(update_doc),
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
@@ -210,14 +335,13 @@ async fn handle_server_update_return(
Ok(update)
}
#[instrument]
#[instrument("UpdateLastPulledTime")]
async fn update_last_pulled_time(repo_name: &str) {
let res = db_client()
.await
.repos
.update_one(
doc! { "name": repo_name },
doc! { "$set": { "info.last_pulled_at": monitor_timestamp() } },
doc! { "$set": { "info.last_pulled_at": komodo_timestamp() } },
)
.await;
if let Err(e) = res {
@@ -227,22 +351,58 @@ async fn update_last_pulled_time(repo_name: &str) {
}
}
impl Resolve<BuildRepo, (User, Update)> for State {
#[instrument(name = "BuildRepo", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl super::BatchExecute for BatchBuildRepo {
type Resource = Repo;
fn single_request(repo: String) -> ExecuteRequest {
ExecuteRequest::CloneRepo(CloneRepo { repo })
}
}
impl Resolve<ExecuteArgs> for BatchBuildRepo {
#[instrument(
"BatchBuildRepo",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
pattern = self.pattern,
)
)]
async fn resolve(
&self,
BuildRepo { repo }: BuildRepo,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let mut repo = resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Execute,
self,
ExecuteArgs { user, id, .. }: &ExecuteArgs,
) -> serror::Result<BatchExecutionResponse> {
Ok(
super::batch_execute::<BatchBuildRepo>(&self.pattern, user)
.await?,
)
}
}
impl Resolve<ExecuteArgs> for BuildRepo {
#[instrument(
"BuildRepo",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
repo = self.repo,
)
)]
async fn resolve(
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let mut repo = get_check_permissions::<Repo>(
&self.repo,
user,
PermissionLevel::Execute.into(),
)
.await?;
if repo.config.builder_id.is_empty() {
return Err(anyhow!("Must attach builder to BuildRepo"));
return Err(anyhow!("Must attach builder to BuildRepo").into());
}
// get the action state for the repo (or insert default).
@@ -254,6 +414,7 @@ impl Resolve<BuildRepo, (User, Update)> for State {
let _action_guard =
action_state.update(|state| state.building = true)?;
let mut update = update.clone();
update_update(update.clone()).await?;
let git_token = git_token(
@@ -313,7 +474,7 @@ impl Resolve<BuildRepo, (User, Update)> for State {
// GET BUILDER PERIPHERY
let (periphery, cleanup_data) = match get_builder_periphery(
let (periphery, cleanup_data) = match connect_builder_periphery(
repo.name.clone(),
None,
builder,
@@ -337,14 +498,22 @@ impl Resolve<BuildRepo, (User, Update)> for State {
// CLONE REPO
// interpolate variables / secrets, returning the sanitizing replacers to send to
// periphery so it may sanitize the final command for safe logging (avoids exposing secret values)
let secret_replacers =
interpolate(&mut repo, &mut update).await?;
let res = tokio::select! {
res = periphery
.request(api::git::CloneRepo {
args: (&repo).into(),
git_token,
environment: Default::default(),
env_file_path: Default::default(),
skip_secret_interp: Default::default(),
environment: repo.config.env_vars()?,
env_file_path: repo.config.env_file_path,
on_clone: repo.config.on_clone.into(),
on_pull: repo.config.on_pull.into(),
skip_secret_interp: repo.config.skip_secret_interp,
replacers: secret_replacers.into_iter().collect()
}) => res,
_ = cancel.cancelled() => {
debug!("build cancelled during clone, cleaning up builder");
@@ -359,15 +528,15 @@ impl Resolve<BuildRepo, (User, Update)> for State {
let commit_message = match res {
Ok(res) => {
debug!("finished repo clone");
let res: RepoActionResponseV1_13 = res.into();
update.logs.extend(res.logs);
update.commit_hash = res.commit_hash.unwrap_or_default();
res.commit_message.unwrap_or_default()
update.logs.extend(res.res.logs);
update.commit_hash = res.res.commit_hash.unwrap_or_default();
res.res.commit_message.unwrap_or_default()
}
Err(e) => {
update.push_error_log(
"clone repo",
format_serror(&e.context("failed to clone repo").into()),
"Clone Repo",
format_serror(&e.context("Failed to clone repo").into()),
);
Default::default()
}
@@ -375,7 +544,7 @@ impl Resolve<BuildRepo, (User, Update)> for State {
update.finalize();
let db = db_client().await;
let db = db_client();
if update.success {
let _ = db
@@ -383,7 +552,7 @@ impl Resolve<BuildRepo, (User, Update)> for State {
.update_one(
doc! { "name": &repo.name },
doc! { "$set": {
"info.last_built_at": monitor_timestamp(),
"info.last_built_at": komodo_timestamp(),
"info.built_hash": &update.commit_hash,
"info.built_message": commit_message
}},
@@ -394,6 +563,8 @@ impl Resolve<BuildRepo, (User, Update)> for State {
// stop the cancel listening task from going forever
cancel.cancel();
// If building on temporary cloud server (AWS),
// this will terminate the server.
cleanup_builder_instance(periphery, cleanup_data, &mut update)
.await;
@@ -405,7 +576,7 @@ impl Resolve<BuildRepo, (User, Update)> for State {
let _ = update_one_by_id(
&db.updates,
&update.id,
mungos::update::Update::Set(update_doc),
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
@@ -415,14 +586,13 @@ impl Resolve<BuildRepo, (User, Update)> for State {
update_update(update.clone()).await?;
if !update.success {
warn!("repo build unsuccessful, alerting...");
let target = update.target.clone();
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: monitor_timestamp(),
resolved_ts: Some(monitor_timestamp()),
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::RepoBuildFailed {
@@ -438,13 +608,13 @@ impl Resolve<BuildRepo, (User, Update)> for State {
}
}
#[instrument(skip(update))]
#[instrument("HandleRepoBuildEarlyReturn", skip(update))]
async fn handle_builder_early_return(
mut update: Update,
repo_id: String,
repo_name: String,
is_cancel: bool,
) -> anyhow::Result<Update> {
) -> serror::Result<Update> {
update.finalize();
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
@@ -452,9 +622,9 @@ async fn handle_builder_early_return(
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db_client().await.updates,
&db_client().updates,
&update.id,
mungos::update::Update::Set(update_doc),
database::mungos::update::Update::Set(update_doc),
None,
)
.await;
@@ -462,14 +632,13 @@ async fn handle_builder_early_return(
}
update_update(update.clone()).await?;
if !update.success && !is_cancel {
warn!("repo build unsuccessful, alerting...");
let target = update.target.clone();
tokio::spawn(async move {
let alert = Alert {
id: Default::default(),
target,
ts: monitor_timestamp(),
resolved_ts: Some(monitor_timestamp()),
ts: komodo_timestamp(),
resolved_ts: Some(komodo_timestamp()),
resolved: true,
level: SeverityLevel::Warning,
data: AlertData::RepoBuildFailed {
@@ -483,14 +652,13 @@ async fn handle_builder_early_return(
Ok(update)
}
#[instrument(skip_all)]
pub async fn validate_cancel_repo_build(
request: &ExecuteRequest,
) -> anyhow::Result<()> {
if let ExecuteRequest::CancelRepoBuild(req) = request {
let repo = resource::get::<Repo>(&req.repo).await?;
let db = db_client().await;
let db = db_client();
let (latest_build, latest_cancel) = tokio::try_join!(
db.updates
@@ -532,17 +700,25 @@ pub async fn validate_cancel_repo_build(
Ok(())
}
impl Resolve<CancelRepoBuild, (User, Update)> for State {
#[instrument(name = "CancelRepoBuild", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
impl Resolve<ExecuteArgs> for CancelRepoBuild {
#[instrument(
"CancelRepoBuild",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
repo = self.repo,
)
)]
async fn resolve(
&self,
CancelRepoBuild { repo }: CancelRepoBuild,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let repo = resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Execute,
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let repo = get_check_permissions::<Repo>(
&self.repo,
user,
PermissionLevel::Execute.into(),
)
.await?;
@@ -554,9 +730,11 @@ impl Resolve<CancelRepoBuild, (User, Update)> for State {
.and_then(|s| s.get().ok().map(|s| s.building))
.unwrap_or_default()
{
return Err(anyhow!("Repo is not building."));
return Err(anyhow!("Repo is not building.").into());
}
let mut update = update.clone();
update.push_simple_log(
"cancel triggered",
"the repo build cancel has been triggered",
@@ -575,17 +753,47 @@ impl Resolve<CancelRepoBuild, (User, Update)> for State {
tokio::spawn(async move {
tokio::time::sleep(Duration::from_secs(60)).await;
if let Err(e) = update_one_by_id(
&db_client().await.updates,
&db_client().updates,
&update_id,
doc! { "$set": { "status": "Complete" } },
None,
)
.await
{
warn!("failed to set CancelRepoBuild Update status Complete after timeout | {e:#}")
warn!(
"failed to set CancelRepoBuild Update status Complete after timeout | {e:#}"
)
}
});
Ok(update)
}
}
#[instrument(
"Interpolate",
skip_all,
fields(
skip_secret_interp = repo.config.skip_secret_interp
)
)]
async fn interpolate(
repo: &mut Repo,
update: &mut Update,
) -> anyhow::Result<HashSet<(String, String)>> {
if !repo.config.skip_secret_interp {
let VariablesAndSecrets { variables, secrets } =
get_variables_and_secrets().await?;
let mut interpolator =
Interpolator::new(Some(&variables), &secrets);
interpolator
.interpolate_repo(repo)?
.push_logs(&mut update.logs);
Ok(interpolator.secret_replacers)
} else {
Ok(Default::default())
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,141 +0,0 @@
use anyhow::{anyhow, Context};
use formatting::format_serror;
use monitor_client::{
api::{execute::LaunchServer, write::CreateServer},
entities::{
permission::PermissionLevel,
server::PartialServerConfig,
server_template::{ServerTemplate, ServerTemplateConfig},
update::Update,
user::User,
},
};
use mungos::mongodb::bson::doc;
use resolver_api::Resolve;
use crate::{
cloud::{
aws::ec2::launch_ec2_instance, hetzner::launch_hetzner_server,
},
helpers::update::update_update,
resource,
state::{db_client, State},
};
impl Resolve<LaunchServer, (User, Update)> for State {
#[instrument(name = "LaunchServer", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
async fn resolve(
&self,
LaunchServer {
name,
server_template,
}: LaunchServer,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
// validate name isn't already taken by another server
if db_client()
.await
.servers
.find_one(doc! {
"name": &name
})
.await
.context("failed to query db for servers")?
.is_some()
{
return Err(anyhow!("name is already taken"));
}
let template = resource::get_check_permissions::<ServerTemplate>(
&server_template,
&user,
PermissionLevel::Execute,
)
.await?;
update.push_simple_log(
"launching server",
format!("{:#?}", template.config),
);
update_update(update.clone()).await?;
let config = match template.config {
ServerTemplateConfig::Aws(config) => {
let region = config.region.clone();
let instance = match launch_ec2_instance(&name, config).await
{
Ok(instance) => instance,
Err(e) => {
update.push_error_log(
"launch server",
format!("failed to launch aws instance\n\n{e:#?}"),
);
update.finalize();
update_update(update.clone()).await?;
return Ok(update);
}
};
update.push_simple_log(
"launch server",
format!(
"successfully launched server {name} on ip {}",
instance.ip
),
);
PartialServerConfig {
address: format!("http://{}:8120", instance.ip).into(),
region: region.into(),
..Default::default()
}
}
ServerTemplateConfig::Hetzner(config) => {
let datacenter = config.datacenter;
let server = match launch_hetzner_server(&name, config).await
{
Ok(server) => server,
Err(e) => {
update.push_error_log(
"launch server",
format!("failed to launch hetzner server\n\n{e:#?}"),
);
update.finalize();
update_update(update.clone()).await?;
return Ok(update);
}
};
update.push_simple_log(
"launch server",
format!(
"successfully launched server {name} on ip {}",
server.ip
),
);
PartialServerConfig {
address: format!("http://{}:8120", server.ip).into(),
region: datacenter.as_ref().to_string().into(),
..Default::default()
}
}
};
match self.resolve(CreateServer { name, config }, user).await {
Ok(server) => {
update.push_simple_log(
"create server",
format!("created server {} ({})", server.name, server.id),
);
update.other_data = server.id;
}
Err(e) => {
update.push_error_log(
"create server",
format_serror(&e.context("failed to create server").into()),
);
}
};
update.finalize();
update_update(update.clone()).await?;
Ok(update)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,87 +1,219 @@
use std::collections::HashMap;
use std::{collections::HashMap, str::FromStr};
use anyhow::{anyhow, Context};
use formatting::{colored, format_serror, Color};
use mongo_indexed::doc;
use monitor_client::{
use anyhow::{Context, anyhow};
use database::mungos::{
by_id::update_one_by_id,
mongodb::bson::{doc, oid::ObjectId},
};
use formatting::{Color, colored, format_serror};
use komodo_client::{
api::{execute::RunSync, write::RefreshResourceSyncPending},
entities::{
self,
self, ResourceTargetVariant,
action::Action,
alerter::Alerter,
build::Build,
builder::Builder,
deployment::Deployment,
monitor_timestamp,
komodo_timestamp,
permission::PermissionLevel,
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
update::{Log, Update},
user::{sync_user, User},
user::sync_user,
},
};
use mungos::{by_id::update_one_by_id, mongodb::bson::to_document};
use resolver_api::Resolve;
use crate::{
api::write::WriteArgs,
helpers::{
query::get_id_to_tags,
sync::{
deploy::{
build_deploy_cache, deploy_from_cache, SyncDeployParams,
},
resource::{
get_updates_for_execution, AllResourcesById, ResourceSync,
},
},
all_resources::AllResourcesById, query::get_id_to_tags,
update::update_update,
},
resource::{self, refresh_resource_sync_state_cache},
state::{db_client, State},
permission::get_check_permissions,
state::{action_states, db_client},
sync::{
ResourceSyncTrait,
deploy::{
SyncDeployParams, build_deploy_cache, deploy_from_cache,
},
execute::{ExecuteResourceSync, get_updates_for_execution},
remote::RemoteResources,
},
};
impl Resolve<RunSync, (User, Update)> for State {
#[instrument(name = "RunSync", skip(self, user, update), fields(user_id = user.id, update_id = update.id))]
use super::ExecuteArgs;
impl Resolve<ExecuteArgs> for RunSync {
#[instrument(
"RunSync",
skip_all,
fields(
id = id.to_string(),
operator = user.id,
update_id = update.id,
sync = self.sync,
resource_type = format!("{:?}", self.resource_type),
resources = format!("{:?}", self.resources),
)
)]
async fn resolve(
&self,
RunSync { sync }: RunSync,
(user, mut update): (User, Update),
) -> anyhow::Result<Update> {
let sync = resource::get_check_permissions::<
entities::sync::ResourceSync,
>(&sync, &user, PermissionLevel::Execute)
self,
ExecuteArgs { user, update, id }: &ExecuteArgs,
) -> serror::Result<Update> {
let RunSync {
sync,
resource_type: match_resource_type,
resources: match_resources,
} = self;
let sync = get_check_permissions::<entities::sync::ResourceSync>(
&sync,
user,
PermissionLevel::Execute.into(),
)
.await?;
if sync.config.repo.is_empty() {
return Err(anyhow!("resource sync repo not configured"));
}
let repo = if !sync.config.files_on_host
&& !sync.config.linked_repo.is_empty()
{
crate::resource::get::<Repo>(&sync.config.linked_repo)
.await?
.into()
} else {
None
};
// get the action state for the sync (or insert default).
let action_state =
action_states().sync.get_or_insert_default(&sync.id).await;
// This will set action state back to default when dropped.
// Will also check to ensure sync not already busy before updating.
let _action_guard =
action_state.update(|state| state.syncing = true)?;
let mut update = update.clone();
// Send update here for FE to recheck action state
update_update(update.clone()).await?;
let (res, logs, hash, message) =
crate::helpers::sync::remote::get_remote_resources(&sync)
let RemoteResources {
resources,
logs,
hash,
message,
file_errors,
..
} =
crate::sync::remote::get_remote_resources(&sync, repo.as_ref())
.await
.context("failed to get remote resources")?;
update.logs.extend(logs);
update_update(update.clone()).await?;
let resources = res?;
if !file_errors.is_empty() {
return Err(
anyhow!("Found file errors. Cannot execute sync.").into(),
);
}
let resources = resources?;
let id_to_tags = get_id_to_tags(None).await?;
let all_resources = AllResourcesById::load().await?;
// Convert all match_resources to names
let match_resources = match_resources.map(|resources| {
resources
.into_iter()
.filter_map(|name_or_id| {
let Some(resource_type) = match_resource_type else {
return Some(name_or_id);
};
match ObjectId::from_str(&name_or_id) {
Ok(_) => match resource_type {
ResourceTargetVariant::Alerter => all_resources
.alerters
.get(&name_or_id)
.map(|a| a.name.clone()),
ResourceTargetVariant::Build => all_resources
.builds
.get(&name_or_id)
.map(|b| b.name.clone()),
ResourceTargetVariant::Builder => all_resources
.builders
.get(&name_or_id)
.map(|b| b.name.clone()),
ResourceTargetVariant::Deployment => all_resources
.deployments
.get(&name_or_id)
.map(|d| d.name.clone()),
ResourceTargetVariant::Procedure => all_resources
.procedures
.get(&name_or_id)
.map(|p| p.name.clone()),
ResourceTargetVariant::Action => all_resources
.actions
.get(&name_or_id)
.map(|p| p.name.clone()),
ResourceTargetVariant::Repo => all_resources
.repos
.get(&name_or_id)
.map(|r| r.name.clone()),
ResourceTargetVariant::Server => all_resources
.servers
.get(&name_or_id)
.map(|s| s.name.clone()),
ResourceTargetVariant::Stack => all_resources
.stacks
.get(&name_or_id)
.map(|s| s.name.clone()),
ResourceTargetVariant::ResourceSync => all_resources
.syncs
.get(&name_or_id)
.map(|s| s.name.clone()),
ResourceTargetVariant::System => None,
},
Err(_) => Some(name_or_id),
}
})
.collect::<Vec<_>>()
});
let deployments_by_name = all_resources
.deployments
.values()
.filter(|deployment| {
Deployment::include_resource(
&deployment.name,
&deployment.config,
match_resource_type,
match_resources.as_deref(),
&deployment.tags,
&id_to_tags,
&sync.config.match_tags,
)
})
.map(|deployment| (deployment.name.clone(), deployment.clone()))
.collect::<HashMap<_, _>>();
let stacks_by_name = all_resources
.stacks
.values()
.filter(|stack| {
Stack::include_resource(
&stack.name,
&stack.config,
match_resource_type,
match_resources.as_deref(),
&stack.tags,
&id_to_tags,
&sync.config.match_tags,
)
})
.map(|stack| (stack.name.clone(), stack.clone()))
.collect::<HashMap<_, _>>();
@@ -90,153 +222,186 @@ impl Resolve<RunSync, (User, Update)> for State {
deployment_map: &deployments_by_name,
stacks: &resources.stacks,
stack_map: &stacks_by_name,
all_resources: &all_resources,
})
.await?;
let (servers_to_create, servers_to_update, servers_to_delete) =
let delete = sync.config.managed || sync.config.delete;
let server_deltas = if sync.config.include_resources {
get_updates_for_execution::<Server>(
resources.servers,
sync.config.delete,
&all_resources,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?;
let (
deployments_to_create,
deployments_to_update,
deployments_to_delete,
) = get_updates_for_execution::<Deployment>(
resources.deployments,
sync.config.delete,
&all_resources,
&id_to_tags,
)
.await?;
let (stacks_to_create, stacks_to_update, stacks_to_delete) =
.await?
} else {
Default::default()
};
let stack_deltas = if sync.config.include_resources {
get_updates_for_execution::<Stack>(
resources.stacks,
sync.config.delete,
&all_resources,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?;
let (builds_to_create, builds_to_update, builds_to_delete) =
.await?
} else {
Default::default()
};
let deployment_deltas = if sync.config.include_resources {
get_updates_for_execution::<Deployment>(
resources.deployments,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?
} else {
Default::default()
};
let build_deltas = if sync.config.include_resources {
get_updates_for_execution::<Build>(
resources.builds,
sync.config.delete,
&all_resources,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?;
let (repos_to_create, repos_to_update, repos_to_delete) =
.await?
} else {
Default::default()
};
let repo_deltas = if sync.config.include_resources {
get_updates_for_execution::<Repo>(
resources.repos,
sync.config.delete,
&all_resources,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?;
let (
procedures_to_create,
procedures_to_update,
procedures_to_delete,
) = get_updates_for_execution::<Procedure>(
resources.procedures,
sync.config.delete,
&all_resources,
&id_to_tags,
)
.await?;
let (builders_to_create, builders_to_update, builders_to_delete) =
.await?
} else {
Default::default()
};
let procedure_deltas = if sync.config.include_resources {
get_updates_for_execution::<Procedure>(
resources.procedures,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?
} else {
Default::default()
};
let action_deltas = if sync.config.include_resources {
get_updates_for_execution::<Action>(
resources.actions,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?
} else {
Default::default()
};
let builder_deltas = if sync.config.include_resources {
get_updates_for_execution::<Builder>(
resources.builders,
sync.config.delete,
&all_resources,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?;
let (alerters_to_create, alerters_to_update, alerters_to_delete) =
.await?
} else {
Default::default()
};
let alerter_deltas = if sync.config.include_resources {
get_updates_for_execution::<Alerter>(
resources.alerters,
sync.config.delete,
&all_resources,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?;
let (
server_templates_to_create,
server_templates_to_update,
server_templates_to_delete,
) = get_updates_for_execution::<ServerTemplate>(
resources.server_templates,
sync.config.delete,
&all_resources,
&id_to_tags,
)
.await?;
let (
resource_syncs_to_create,
resource_syncs_to_update,
resource_syncs_to_delete,
) = get_updates_for_execution::<entities::sync::ResourceSync>(
resources.resource_syncs,
sync.config.delete,
&all_resources,
&id_to_tags,
)
.await?;
.await?
} else {
Default::default()
};
let resource_sync_deltas = if sync.config.include_resources {
get_updates_for_execution::<entities::sync::ResourceSync>(
resources.resource_syncs,
delete,
match_resource_type,
match_resources.as_deref(),
&id_to_tags,
&sync.config.match_tags,
)
.await?
} else {
Default::default()
};
let (
variables_to_create,
variables_to_update,
variables_to_delete,
) = crate::helpers::sync::variables::get_updates_for_execution(
resources.variables,
sync.config.delete,
)
.await?;
) = if match_resource_type.is_none()
&& match_resources.is_none()
&& sync.config.include_variables
{
crate::sync::variables::get_updates_for_execution(
resources.variables,
delete,
)
.await?
} else {
Default::default()
};
let (
user_groups_to_create,
user_groups_to_update,
user_groups_to_delete,
) = crate::helpers::sync::user_groups::get_updates_for_execution(
resources.user_groups,
sync.config.delete,
&all_resources,
)
.await?;
) = if match_resource_type.is_none()
&& match_resources.is_none()
&& sync.config.include_user_groups
{
crate::sync::user_groups::get_updates_for_execution(
resources.user_groups,
delete,
)
.await?
} else {
Default::default()
};
if deploy_cache.is_empty()
&& resource_syncs_to_create.is_empty()
&& resource_syncs_to_update.is_empty()
&& resource_syncs_to_delete.is_empty()
&& server_templates_to_create.is_empty()
&& server_templates_to_update.is_empty()
&& server_templates_to_delete.is_empty()
&& servers_to_create.is_empty()
&& servers_to_update.is_empty()
&& servers_to_delete.is_empty()
&& deployments_to_create.is_empty()
&& deployments_to_update.is_empty()
&& deployments_to_delete.is_empty()
&& stacks_to_create.is_empty()
&& stacks_to_update.is_empty()
&& stacks_to_delete.is_empty()
&& builds_to_create.is_empty()
&& builds_to_update.is_empty()
&& builds_to_delete.is_empty()
&& builders_to_create.is_empty()
&& builders_to_update.is_empty()
&& builders_to_delete.is_empty()
&& alerters_to_create.is_empty()
&& alerters_to_update.is_empty()
&& alerters_to_delete.is_empty()
&& repos_to_create.is_empty()
&& repos_to_update.is_empty()
&& repos_to_delete.is_empty()
&& procedures_to_create.is_empty()
&& procedures_to_update.is_empty()
&& procedures_to_delete.is_empty()
&& resource_sync_deltas.no_changes()
&& server_deltas.no_changes()
&& deployment_deltas.no_changes()
&& stack_deltas.no_changes()
&& build_deltas.no_changes()
&& builder_deltas.no_changes()
&& alerter_deltas.no_changes()
&& repo_deltas.no_changes()
&& procedure_deltas.no_changes()
&& action_deltas.no_changes()
&& user_groups_to_create.is_empty()
&& user_groups_to_update.is_empty()
&& user_groups_to_delete.is_empty()
@@ -261,7 +426,7 @@ impl Resolve<RunSync, (User, Update)> for State {
// No deps
maybe_extend(
&mut update.logs,
crate::helpers::sync::variables::run_updates(
crate::sync::variables::run_updates(
variables_to_create,
variables_to_update,
variables_to_delete,
@@ -270,7 +435,7 @@ impl Resolve<RunSync, (User, Update)> for State {
);
maybe_extend(
&mut update.logs,
crate::helpers::sync::user_groups::run_updates(
crate::sync::user_groups::run_updates(
user_groups_to_create,
user_groups_to_update,
user_groups_to_delete,
@@ -279,115 +444,65 @@ impl Resolve<RunSync, (User, Update)> for State {
);
maybe_extend(
&mut update.logs,
entities::sync::ResourceSync::run_updates(
resource_syncs_to_create,
resource_syncs_to_update,
resource_syncs_to_delete,
)
.await,
ResourceSync::execute_sync_updates(resource_sync_deltas).await,
);
maybe_extend(
&mut update.logs,
ServerTemplate::run_updates(
server_templates_to_create,
server_templates_to_update,
server_templates_to_delete,
)
.await,
Server::execute_sync_updates(server_deltas).await,
);
maybe_extend(
&mut update.logs,
Server::run_updates(
servers_to_create,
servers_to_update,
servers_to_delete,
)
.await,
Alerter::execute_sync_updates(alerter_deltas).await,
);
maybe_extend(
&mut update.logs,
Alerter::run_updates(
alerters_to_create,
alerters_to_update,
alerters_to_delete,
)
.await,
Action::execute_sync_updates(action_deltas).await,
);
// Dependent on server
maybe_extend(
&mut update.logs,
Builder::run_updates(
builders_to_create,
builders_to_update,
builders_to_delete,
)
.await,
Builder::execute_sync_updates(builder_deltas).await,
);
maybe_extend(
&mut update.logs,
Repo::run_updates(
repos_to_create,
repos_to_update,
repos_to_delete,
)
.await,
Repo::execute_sync_updates(repo_deltas).await,
);
// Dependant on builder
maybe_extend(
&mut update.logs,
Build::run_updates(
builds_to_create,
builds_to_update,
builds_to_delete,
)
.await,
Build::execute_sync_updates(build_deltas).await,
);
// Dependant on server / build
maybe_extend(
&mut update.logs,
Deployment::run_updates(
deployments_to_create,
deployments_to_update,
deployments_to_delete,
)
.await,
Deployment::execute_sync_updates(deployment_deltas).await,
);
// stack only depends on server, but maybe will depend on build later.
maybe_extend(
&mut update.logs,
Stack::run_updates(
stacks_to_create,
stacks_to_update,
stacks_to_delete,
)
.await,
Stack::execute_sync_updates(stack_deltas).await,
);
// Dependant on everything
maybe_extend(
&mut update.logs,
Procedure::run_updates(
procedures_to_create,
procedures_to_update,
procedures_to_delete,
)
.await,
Procedure::execute_sync_updates(procedure_deltas).await,
);
// Execute the deploy cache
deploy_from_cache(deploy_cache, &mut update.logs).await;
let db = db_client().await;
let db = db_client();
if let Err(e) = update_one_by_id(
&db.resource_syncs,
&sync.id,
doc! {
"$set": {
"info.last_sync_ts": monitor_timestamp(),
"info.last_sync_ts": komodo_timestamp(),
"info.last_sync_hash": hash,
"info.last_sync_message": message,
}
@@ -402,39 +517,27 @@ impl Resolve<RunSync, (User, Update)> for State {
)
}
if let Err(e) = State
.resolve(
RefreshResourceSyncPending { sync: sync.id },
sync_user().to_owned(),
)
if let Err(e) = (RefreshResourceSyncPending { sync: sync.id })
.resolve(&WriteArgs {
user: sync_user().to_owned(),
})
.await
{
warn!("failed to refresh sync {} after run | {e:#}", sync.name);
warn!(
"failed to refresh sync {} after run | {:#}",
sync.name, e.error
);
update.push_error_log(
"refresh sync",
format_serror(
&e.context("failed to refresh sync pending after run")
&e.error
.context("failed to refresh sync pending after run")
.into(),
),
);
}
update.finalize();
// Need to manually update the update before cache refresh,
// and before broadcast with add_update.
// The Err case of to_document should be unreachable,
// but will fail to update cache in that case.
if let Ok(update_doc) = to_document(&update) {
let _ = update_one_by_id(
&db.updates,
&update.id,
mungos::update::Update::Set(update_doc),
None,
)
.await;
refresh_resource_sync_state_cache().await;
}
update_update(update.clone()).await?;
Ok(update)

View File

@@ -1,5 +1,11 @@
pub mod auth;
pub mod execute;
pub mod read;
pub mod terminal;
pub mod user;
pub mod write;
#[derive(serde::Deserialize)]
struct Variant {
variant: String,
}

View File

@@ -0,0 +1,138 @@
use anyhow::Context;
use komodo_client::{
api::read::*,
entities::{
action::{
Action, ActionActionState, ActionListItem, ActionState,
},
permission::PermissionLevel,
},
};
use resolver_api::Resolve;
use crate::{
helpers::query::get_all_tags,
permission::get_check_permissions,
resource,
state::{action_state_cache, action_states},
};
use super::ReadArgs;
impl Resolve<ReadArgs> for GetAction {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Action> {
Ok(
get_check_permissions::<Action>(
&self.action,
user,
PermissionLevel::Read.into(),
)
.await?,
)
}
}
impl Resolve<ReadArgs> for ListActions {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<ActionListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<Action>(self.query, user, &all_tags)
.await?,
)
}
}
impl Resolve<ReadArgs> for ListFullActions {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullActionsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Action>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<ReadArgs> for GetActionActionState {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ActionActionState> {
let action = get_check_permissions::<Action>(
&self.action,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()
.action
.get(&action.id)
.await
.unwrap_or_default()
.get()?;
Ok(action_state)
}
}
impl Resolve<ReadArgs> for GetActionsSummary {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetActionsSummaryResponse> {
let actions = resource::list_full_for_user::<Action>(
Default::default(),
user,
&[],
)
.await
.context("failed to get actions from db")?;
let mut res = GetActionsSummaryResponse::default();
let cache = action_state_cache();
let action_states = action_states();
for action in actions {
res.total += 1;
match (
cache.get(&action.id).await.unwrap_or_default(),
action_states
.action
.get(&action.id)
.await
.unwrap_or_default()
.get()?,
) {
(_, action_states) if action_states.running > 0 => {
res.running += action_states.running;
}
(ActionState::Ok, _) => res.ok += 1,
(ActionState::Failed, _) => res.failed += 1,
(ActionState::Unknown, _) => res.unknown += 1,
// will never come off the cache in the running state, since that comes from action states
(ActionState::Running, _) => unreachable!(),
}
}
Ok(res)
}
}

View File

@@ -1,52 +1,61 @@
use anyhow::Context;
use monitor_client::{
api::read::{
GetAlert, GetAlertResponse, ListAlerts, ListAlertsResponse,
},
entities::{deployment::Deployment, server::Server, user::User},
};
use mungos::{
use database::mungos::{
by_id::find_one_by_id,
find::find_collect,
mongodb::{bson::doc, options::FindOptions},
};
use komodo_client::{
api::read::{
GetAlert, GetAlertResponse, ListAlerts, ListAlertsResponse,
},
entities::{
deployment::Deployment, server::Server, stack::Stack,
sync::ResourceSync,
},
};
use resolver_api::Resolve;
use crate::{
config::core_config,
resource::get_resource_ids_for_user,
state::{db_client, State},
config::core_config, permission::get_resource_ids_for_user,
state::db_client,
};
use super::ReadArgs;
const NUM_ALERTS_PER_PAGE: u64 = 100;
impl Resolve<ListAlerts, User> for State {
impl Resolve<ReadArgs> for ListAlerts {
async fn resolve(
&self,
ListAlerts { query, page }: ListAlerts,
user: User,
) -> anyhow::Result<ListAlertsResponse> {
let mut query = query.unwrap_or_default();
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListAlertsResponse> {
let mut query = self.query.unwrap_or_default();
if !user.admin && !core_config().transparent_mode {
let server_ids =
get_resource_ids_for_user::<Server>(&user).await?;
get_resource_ids_for_user::<Server>(user).await?;
let stack_ids =
get_resource_ids_for_user::<Stack>(user).await?;
let deployment_ids =
get_resource_ids_for_user::<Deployment>(&user).await?;
get_resource_ids_for_user::<Deployment>(user).await?;
let sync_ids =
get_resource_ids_for_user::<ResourceSync>(user).await?;
query.extend(doc! {
"$or": [
{ "target.type": "Server", "target.id": { "$in": &server_ids } },
{ "target.type": "Stack", "target.id": { "$in": &stack_ids } },
{ "target.type": "Deployment", "target.id": { "$in": &deployment_ids } },
{ "target.type": "ResourceSync", "target.id": { "$in": &sync_ids } },
]
});
}
let alerts = find_collect(
&db_client().await.alerts,
&db_client().alerts,
query,
FindOptions::builder()
.sort(doc! { "ts": -1 })
.limit(NUM_ALERTS_PER_PAGE as i64)
.skip(page * NUM_ALERTS_PER_PAGE)
.skip(self.page * NUM_ALERTS_PER_PAGE)
.build(),
)
.await
@@ -55,7 +64,7 @@ impl Resolve<ListAlerts, User> for State {
let next_page = if alerts.len() < NUM_ALERTS_PER_PAGE as usize {
None
} else {
Some((page + 1) as i64)
Some((self.page + 1) as i64)
};
let res = ListAlertsResponse { next_page, alerts };
@@ -64,15 +73,16 @@ impl Resolve<ListAlerts, User> for State {
}
}
impl Resolve<GetAlert, User> for State {
impl Resolve<ReadArgs> for GetAlert {
async fn resolve(
&self,
GetAlert { id }: GetAlert,
_: User,
) -> anyhow::Result<GetAlertResponse> {
find_one_by_id(&db_client().await.alerts, &id)
.await
.context("failed to query db for alert")?
.context("no alert found with given id")
self,
_: &ReadArgs,
) -> serror::Result<GetAlertResponse> {
Ok(
find_one_by_id(&db_client().alerts, &self.id)
.await
.context("failed to query db for alert")?
.context("no alert found with given id")?,
)
}
}

View File

@@ -1,73 +1,90 @@
use anyhow::Context;
use mongo_indexed::Document;
use monitor_client::{
use database::mongo_indexed::Document;
use database::mungos::mongodb::bson::doc;
use komodo_client::{
api::read::*,
entities::{
alerter::{Alerter, AlerterListItem},
permission::PermissionLevel,
user::User,
},
};
use mungos::mongodb::bson::doc;
use resolver_api::Resolve;
use crate::{
resource,
state::{db_client, State},
helpers::query::get_all_tags, permission::get_check_permissions,
resource, state::db_client,
};
impl Resolve<GetAlerter, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetAlerter {
async fn resolve(
&self,
GetAlerter { alerter }: GetAlerter,
user: User,
) -> anyhow::Result<Alerter> {
resource::get_check_permissions::<Alerter>(
&alerter,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Alerter> {
Ok(
get_check_permissions::<Alerter>(
&self.alerter,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListAlerters, User> for State {
impl Resolve<ReadArgs> for ListAlerters {
async fn resolve(
&self,
ListAlerters { query }: ListAlerters,
user: User,
) -> anyhow::Result<Vec<AlerterListItem>> {
resource::list_for_user::<Alerter>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<AlerterListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<Alerter>(self.query, user, &all_tags)
.await?,
)
}
}
impl Resolve<ListFullAlerters, User> for State {
impl Resolve<ReadArgs> for ListFullAlerters {
async fn resolve(
&self,
ListFullAlerters { query }: ListFullAlerters,
user: User,
) -> anyhow::Result<ListFullAlertersResponse> {
resource::list_full_for_user::<Alerter>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullAlertersResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Alerter>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetAlertersSummary, User> for State {
impl Resolve<ReadArgs> for GetAlertersSummary {
async fn resolve(
&self,
GetAlertersSummary {}: GetAlertersSummary,
user: User,
) -> anyhow::Result<GetAlertersSummaryResponse> {
let query =
match resource::get_resource_ids_for_user::<Alerter>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetAlertersSummaryResponse> {
let query = match resource::get_resource_object_ids_for_user::<
Alerter,
>(user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let total = db_client()
.await
.alerters
.count_documents(query)
.await

View File

@@ -2,77 +2,92 @@ use std::collections::{HashMap, HashSet};
use anyhow::Context;
use async_timing_util::unix_timestamp_ms;
use futures::TryStreamExt;
use monitor_client::{
api::read::*,
entities::{
build::{Build, BuildActionState, BuildListItem, BuildState},
config::core::CoreConfig,
permission::PermissionLevel,
update::UpdateStatus,
user::User,
Operation,
},
};
use mungos::{
use database::mungos::{
find::find_collect,
mongodb::{bson::doc, options::FindOptions},
};
use futures_util::TryStreamExt;
use komodo_client::{
api::read::*,
entities::{
Operation,
build::{Build, BuildActionState, BuildListItem, BuildState},
permission::PermissionLevel,
update::UpdateStatus,
},
};
use resolver_api::Resolve;
use crate::{
config::core_config,
helpers::query::get_all_tags,
permission::get_check_permissions,
resource,
state::{
action_states, build_state_cache, db_client, github_client, State,
},
state::{action_states, build_state_cache, db_client},
};
impl Resolve<GetBuild, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetBuild {
async fn resolve(
&self,
GetBuild { build }: GetBuild,
user: User,
) -> anyhow::Result<Build> {
resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Build> {
Ok(
get_check_permissions::<Build>(
&self.build,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListBuilds, User> for State {
impl Resolve<ReadArgs> for ListBuilds {
async fn resolve(
&self,
ListBuilds { query }: ListBuilds,
user: User,
) -> anyhow::Result<Vec<BuildListItem>> {
resource::list_for_user::<Build>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<BuildListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<Build>(self.query, user, &all_tags)
.await?,
)
}
}
impl Resolve<ListFullBuilds, User> for State {
impl Resolve<ReadArgs> for ListFullBuilds {
async fn resolve(
&self,
ListFullBuilds { query }: ListFullBuilds,
user: User,
) -> anyhow::Result<ListFullBuildsResponse> {
resource::list_full_for_user::<Build>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullBuildsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Build>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetBuildActionState, User> for State {
impl Resolve<ReadArgs> for GetBuildActionState {
async fn resolve(
&self,
GetBuildActionState { build }: GetBuildActionState,
user: User,
) -> anyhow::Result<BuildActionState> {
let build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<BuildActionState> {
let build = get_check_permissions::<Build>(
&self.build,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()
@@ -85,15 +100,15 @@ impl Resolve<GetBuildActionState, User> for State {
}
}
impl Resolve<GetBuildsSummary, User> for State {
impl Resolve<ReadArgs> for GetBuildsSummary {
async fn resolve(
&self,
GetBuildsSummary {}: GetBuildsSummary,
user: User,
) -> anyhow::Result<GetBuildsSummaryResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetBuildsSummaryResponse> {
let builds = resource::list_full_for_user::<Build>(
Default::default(),
&user,
user,
&[],
)
.await
.context("failed to get all builds")?;
@@ -132,20 +147,18 @@ impl Resolve<GetBuildsSummary, User> for State {
const ONE_DAY_MS: i64 = 86400000;
impl Resolve<GetBuildMonthlyStats, User> for State {
impl Resolve<ReadArgs> for GetBuildMonthlyStats {
async fn resolve(
&self,
GetBuildMonthlyStats { page }: GetBuildMonthlyStats,
_: User,
) -> anyhow::Result<GetBuildMonthlyStatsResponse> {
self,
_: &ReadArgs,
) -> serror::Result<GetBuildMonthlyStatsResponse> {
let curr_ts = unix_timestamp_ms() as i64;
let next_day = curr_ts - curr_ts % ONE_DAY_MS + ONE_DAY_MS;
let close_ts = next_day - page as i64 * 30 * ONE_DAY_MS;
let close_ts = next_day - self.page as i64 * 30 * ONE_DAY_MS;
let open_ts = close_ts - 30 * ONE_DAY_MS;
let mut build_updates = db_client()
.await
.updates
.find(doc! {
"start_ts": {
@@ -190,22 +203,22 @@ fn ms_to_hour(duration: i64) -> f64 {
duration as f64 / MS_TO_HOUR_DIVISOR
}
impl Resolve<ListBuildVersions, User> for State {
impl Resolve<ReadArgs> for ListBuildVersions {
async fn resolve(
&self,
ListBuildVersions {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<BuildVersionResponseItem>> {
let ListBuildVersions {
build,
major,
minor,
patch,
limit,
}: ListBuildVersions,
user: User,
) -> anyhow::Result<Vec<BuildVersionResponseItem>> {
let build = resource::get_check_permissions::<Build>(
} = self;
let build = get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
@@ -229,7 +242,7 @@ impl Resolve<ListBuildVersions, User> for State {
}
let versions = find_collect(
&db_client().await.updates,
&db_client().updates,
filter,
FindOptions::builder()
.sort(doc! { "_id": -1 })
@@ -247,15 +260,21 @@ impl Resolve<ListBuildVersions, User> for State {
}
}
impl Resolve<ListCommonBuildExtraArgs, User> for State {
impl Resolve<ReadArgs> for ListCommonBuildExtraArgs {
async fn resolve(
&self,
ListCommonBuildExtraArgs { query }: ListCommonBuildExtraArgs,
user: User,
) -> anyhow::Result<ListCommonBuildExtraArgsResponse> {
let builds = resource::list_full_for_user::<Build>(query, &user)
.await
.context("failed to get resources matching query")?;
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListCommonBuildExtraArgsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
let builds = resource::list_full_for_user::<Build>(
self.query, user, &all_tags,
)
.await
.context("failed to get resources matching query")?;
// first collect with guaranteed uniqueness
let mut res = HashSet::<String>::new();
@@ -271,78 +290,3 @@ impl Resolve<ListCommonBuildExtraArgs, User> for State {
Ok(res)
}
}
impl Resolve<GetBuildWebhookEnabled, User> for State {
async fn resolve(
&self,
GetBuildWebhookEnabled { build }: GetBuildWebhookEnabled,
user: User,
) -> anyhow::Result<GetBuildWebhookEnabledResponse> {
let Some(github) = github_client() else {
return Ok(GetBuildWebhookEnabledResponse {
managed: false,
enabled: false,
});
};
let build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Read,
)
.await?;
if build.config.git_provider != "github.com"
|| build.config.repo.is_empty()
{
return Ok(GetBuildWebhookEnabledResponse {
managed: false,
enabled: false,
});
}
let mut split = build.config.repo.split('/');
let owner = split.next().context("Build repo has no owner")?;
let Some(github) = github.get(owner) else {
return Ok(GetBuildWebhookEnabledResponse {
managed: false,
enabled: false,
});
};
let repo =
split.next().context("Build repo has no repo after the /")?;
let github_repos = github.repos();
let webhooks = github_repos
.list_all_webhooks(owner, repo)
.await
.context("failed to list all webhooks on repo")?
.body;
let CoreConfig {
host,
webhook_base_url,
..
} = core_config();
let host = webhook_base_url.as_ref().unwrap_or(host);
let url = format!("{host}/listener/github/build/{}", build.id);
for webhook in webhooks {
if webhook.active && webhook.config.url == url {
return Ok(GetBuildWebhookEnabledResponse {
managed: true,
enabled: true,
});
}
}
Ok(GetBuildWebhookEnabledResponse {
managed: true,
enabled: false,
})
}
}

View File

@@ -1,73 +1,90 @@
use anyhow::Context;
use mongo_indexed::Document;
use monitor_client::{
use database::mongo_indexed::Document;
use database::mungos::mongodb::bson::doc;
use komodo_client::{
api::read::*,
entities::{
builder::{Builder, BuilderListItem},
permission::PermissionLevel,
user::User,
},
};
use mungos::mongodb::bson::doc;
use resolver_api::Resolve;
use crate::{
resource,
state::{db_client, State},
helpers::query::get_all_tags, permission::get_check_permissions,
resource, state::db_client,
};
impl Resolve<GetBuilder, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetBuilder {
async fn resolve(
&self,
GetBuilder { builder }: GetBuilder,
user: User,
) -> anyhow::Result<Builder> {
resource::get_check_permissions::<Builder>(
&builder,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Builder> {
Ok(
get_check_permissions::<Builder>(
&self.builder,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListBuilders, User> for State {
impl Resolve<ReadArgs> for ListBuilders {
async fn resolve(
&self,
ListBuilders { query }: ListBuilders,
user: User,
) -> anyhow::Result<Vec<BuilderListItem>> {
resource::list_for_user::<Builder>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<BuilderListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<Builder>(self.query, user, &all_tags)
.await?,
)
}
}
impl Resolve<ListFullBuilders, User> for State {
impl Resolve<ReadArgs> for ListFullBuilders {
async fn resolve(
&self,
ListFullBuilders { query }: ListFullBuilders,
user: User,
) -> anyhow::Result<ListFullBuildersResponse> {
resource::list_full_for_user::<Builder>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullBuildersResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Builder>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetBuildersSummary, User> for State {
impl Resolve<ReadArgs> for GetBuildersSummary {
async fn resolve(
&self,
GetBuildersSummary {}: GetBuildersSummary,
user: User,
) -> anyhow::Result<GetBuildersSummaryResponse> {
let query =
match resource::get_resource_ids_for_user::<Builder>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetBuildersSummaryResponse> {
let query = match resource::get_resource_object_ids_for_user::<
Builder,
>(user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let total = db_client()
.await
.builders
.count_documents(query)
.await

View File

@@ -1,73 +1,104 @@
use std::{cmp, collections::HashSet};
use anyhow::{anyhow, Context};
use monitor_client::{
use anyhow::{Context, anyhow};
use komodo_client::{
api::read::*,
entities::{
deployment::{
Deployment, DeploymentActionState, DeploymentConfig,
DeploymentListItem, DeploymentState, DockerContainerStats,
DeploymentListItem, DeploymentState,
},
docker::container::{Container, ContainerStats},
permission::PermissionLevel,
server::Server,
server::{Server, ServerState},
update::Log,
user::User,
},
};
use periphery_client::api;
use periphery_client::api::{self, container::InspectContainer};
use resolver_api::Resolve;
use crate::{
helpers::periphery_client,
helpers::{periphery_client, query::get_all_tags},
permission::get_check_permissions,
resource,
state::{action_states, deployment_status_cache, State},
state::{
action_states, deployment_status_cache, server_status_cache,
},
};
impl Resolve<GetDeployment, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetDeployment {
async fn resolve(
&self,
GetDeployment { deployment }: GetDeployment,
user: User,
) -> anyhow::Result<Deployment> {
resource::get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Deployment> {
Ok(
get_check_permissions::<Deployment>(
&self.deployment,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListDeployments, User> for State {
impl Resolve<ReadArgs> for ListDeployments {
async fn resolve(
&self,
ListDeployments { query }: ListDeployments,
user: User,
) -> anyhow::Result<Vec<DeploymentListItem>> {
resource::list_for_user::<Deployment>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<DeploymentListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
let only_update_available = self.query.specific.update_available;
let deployments = resource::list_for_user::<Deployment>(
self.query, user, &all_tags,
)
.await?;
let deployments = if only_update_available {
deployments
.into_iter()
.filter(|deployment| deployment.info.update_available)
.collect()
} else {
deployments
};
Ok(deployments)
}
}
impl Resolve<ListFullDeployments, User> for State {
impl Resolve<ReadArgs> for ListFullDeployments {
async fn resolve(
&self,
ListFullDeployments { query }: ListFullDeployments,
user: User,
) -> anyhow::Result<ListFullDeploymentsResponse> {
resource::list_full_for_user::<Deployment>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullDeploymentsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Deployment>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetDeploymentContainer, User> for State {
impl Resolve<ReadArgs> for GetDeploymentContainer {
async fn resolve(
&self,
GetDeploymentContainer { deployment }: GetDeploymentContainer,
user: User,
) -> anyhow::Result<GetDeploymentContainerResponse> {
let deployment = resource::get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetDeploymentContainerResponse> {
let deployment = get_check_permissions::<Deployment>(
&self.deployment,
user,
PermissionLevel::Read.into(),
)
.await?;
let status = deployment_status_cache()
@@ -84,110 +115,168 @@ impl Resolve<GetDeploymentContainer, User> for State {
const MAX_LOG_LENGTH: u64 = 5000;
impl Resolve<GetLog, User> for State {
impl Resolve<ReadArgs> for GetDeploymentLog {
async fn resolve(
&self,
GetLog { deployment, tail }: GetLog,
user: User,
) -> anyhow::Result<Log> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Log> {
let GetDeploymentLog {
deployment,
tail,
timestamps,
} = self;
let Deployment {
name,
config: DeploymentConfig { server_id, .. },
..
} = resource::get_check_permissions::<Deployment>(
} = get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.logs(),
)
.await?;
if server_id.is_empty() {
return Ok(Log::default());
}
let server = resource::get::<Server>(&server_id).await?;
periphery_client(&server)?
let res = periphery_client(&server)
.await?
.request(api::container::GetContainerLog {
name,
tail: cmp::min(tail, MAX_LOG_LENGTH),
timestamps,
})
.await
.context("failed at call to periphery")
.context("failed at call to periphery")?;
Ok(res)
}
}
impl Resolve<SearchLog, User> for State {
impl Resolve<ReadArgs> for SearchDeploymentLog {
async fn resolve(
&self,
SearchLog {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Log> {
let SearchDeploymentLog {
deployment,
terms,
combinator,
invert,
}: SearchLog,
user: User,
) -> anyhow::Result<Log> {
timestamps,
} = self;
let Deployment {
name,
config: DeploymentConfig { server_id, .. },
..
} = resource::get_check_permissions::<Deployment>(
} = get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.logs(),
)
.await?;
if server_id.is_empty() {
return Ok(Log::default());
}
let server = resource::get::<Server>(&server_id).await?;
periphery_client(&server)?
let res = periphery_client(&server)
.await?
.request(api::container::GetContainerLogSearch {
name,
terms,
combinator,
invert,
timestamps,
})
.await
.context("failed at call to periphery")
.context("failed at call to periphery")?;
Ok(res)
}
}
impl Resolve<GetDeploymentStats, User> for State {
impl Resolve<ReadArgs> for InspectDeploymentContainer {
async fn resolve(
&self,
GetDeploymentStats { deployment }: GetDeploymentStats,
user: User,
) -> anyhow::Result<DockerContainerStats> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Container> {
let InspectDeploymentContainer { deployment } = self;
let Deployment {
name,
config: DeploymentConfig { server_id, .. },
..
} = resource::get_check_permissions::<Deployment>(
} = get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.inspect(),
)
.await?;
if server_id.is_empty() {
return Err(anyhow!("deployment has no server attached"));
return Err(
anyhow!(
"Cannot inspect deployment, not attached to any server"
)
.into(),
);
}
let server = resource::get::<Server>(&server_id).await?;
periphery_client(&server)?
.request(api::container::GetContainerStats { name })
.await
.context("failed to get stats from periphery")
let cache = server_status_cache()
.get_or_insert_default(&server.id)
.await;
if cache.state != ServerState::Ok {
return Err(
anyhow!(
"Cannot inspect container: server is {:?}",
cache.state
)
.into(),
);
}
let res = periphery_client(&server)
.await?
.request(InspectContainer { name })
.await?;
Ok(res)
}
}
impl Resolve<GetDeploymentActionState, User> for State {
impl Resolve<ReadArgs> for GetDeploymentStats {
async fn resolve(
&self,
GetDeploymentActionState { deployment }: GetDeploymentActionState,
user: User,
) -> anyhow::Result<DeploymentActionState> {
let deployment = resource::get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ContainerStats> {
let Deployment {
name,
config: DeploymentConfig { server_id, .. },
..
} = get_check_permissions::<Deployment>(
&self.deployment,
user,
PermissionLevel::Read.into(),
)
.await?;
if server_id.is_empty() {
return Err(
anyhow!("deployment has no server attached").into(),
);
}
let server = resource::get::<Server>(&server_id).await?;
let res = periphery_client(&server)
.await?
.request(api::container::GetContainerStats { name })
.await
.context("failed to get stats from periphery")?;
Ok(res)
}
}
impl Resolve<ReadArgs> for GetDeploymentActionState {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<DeploymentActionState> {
let deployment = get_check_permissions::<Deployment>(
&self.deployment,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()
@@ -200,15 +289,15 @@ impl Resolve<GetDeploymentActionState, User> for State {
}
}
impl Resolve<GetDeploymentsSummary, User> for State {
impl Resolve<ReadArgs> for GetDeploymentsSummary {
async fn resolve(
&self,
GetDeploymentsSummary {}: GetDeploymentsSummary,
user: User,
) -> anyhow::Result<GetDeploymentsSummaryResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetDeploymentsSummaryResponse> {
let deployments = resource::list_full_for_user::<Deployment>(
Default::default(),
&user,
user,
&[],
)
.await
.context("failed to get deployments from db")?;
@@ -222,14 +311,19 @@ impl Resolve<GetDeploymentsSummary, User> for State {
DeploymentState::Running => {
res.running += 1;
}
DeploymentState::Unknown => {
res.unknown += 1;
DeploymentState::Exited | DeploymentState::Paused => {
res.stopped += 1;
}
DeploymentState::NotDeployed => {
res.not_deployed += 1;
}
DeploymentState::Unknown => {
if !deployment.template {
res.unknown += 1;
}
}
_ => {
res.stopped += 1;
res.unhealthy += 1;
}
}
}
@@ -237,16 +331,21 @@ impl Resolve<GetDeploymentsSummary, User> for State {
}
}
impl Resolve<ListCommonDeploymentExtraArgs, User> for State {
impl Resolve<ReadArgs> for ListCommonDeploymentExtraArgs {
async fn resolve(
&self,
ListCommonDeploymentExtraArgs { query }: ListCommonDeploymentExtraArgs,
user: User,
) -> anyhow::Result<ListCommonDeploymentExtraArgsResponse> {
let deployments =
resource::list_full_for_user::<Deployment>(query, &user)
.await
.context("failed to get resources matching query")?;
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListCommonDeploymentExtraArgsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
let deployments = resource::list_full_for_user::<Deployment>(
self.query, user, &all_tags,
)
.await
.context("failed to get resources matching query")?;
// first collect with guaranteed uniqueness
let mut res = HashSet::<String>::new();

View File

@@ -1,46 +1,52 @@
use std::{collections::HashSet, sync::OnceLock, time::Instant};
use std::{collections::HashSet, time::Instant};
use anyhow::{anyhow, Context};
use axum::{middleware, routing::post, Extension, Router};
use axum_extra::{headers::ContentType, TypedHeader};
use monitor_client::{
use anyhow::{Context, anyhow};
use axum::{
Extension, Router, extract::Path, middleware, routing::post,
};
use komodo_client::{
api::read::*,
entities::{
ResourceTarget,
build::Build,
builder::{Builder, BuilderConfig},
config::{DockerRegistry, GitProvider},
repo::Repo,
server::Server,
sync::ResourceSync,
update::ResourceTarget,
user::User,
},
};
use resolver_api::{
derive::Resolver, Resolve, ResolveToString, Resolver,
};
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use serror::Json;
use typeshare::typeshare;
use uuid::Uuid;
use crate::{
auth::auth_request, config::core_config, helpers::periphery_client,
resource, state::State,
auth::auth_request,
config::{core_config, core_keys},
helpers::periphery_client,
resource,
};
use super::Variant;
mod action;
mod alert;
mod alerter;
mod build;
mod builder;
mod deployment;
mod onboarding_key;
mod permission;
mod procedure;
mod provider;
mod repo;
mod search;
mod schedule;
mod server;
mod server_template;
mod stack;
mod sync;
mod tag;
@@ -50,25 +56,26 @@ mod user;
mod user_group;
mod variable;
pub struct ReadArgs {
pub user: User,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Resolver)]
#[resolver_target(State)]
#[resolver_args(User)]
#[derive(Serialize, Deserialize, Debug, Clone, Resolve)]
#[args(ReadArgs)]
#[response(Response)]
#[error(serror::Error)]
#[serde(tag = "type", content = "params")]
enum ReadRequest {
#[to_string_resolver]
GetVersion(GetVersion),
#[to_string_resolver]
GetCoreInfo(GetCoreInfo),
#[to_string_resolver]
ListAwsEcrLabels(ListAwsEcrLabels),
ListSecrets(ListSecrets),
ListGitProvidersFromConfig(ListGitProvidersFromConfig),
ListDockerRegistriesFromConfig(ListDockerRegistriesFromConfig),
// ==== USER ====
GetUsername(GetUsername),
GetPermissionLevel(GetPermissionLevel),
GetPermission(GetPermission),
FindUser(FindUser),
ListUsers(ListUsers),
ListApiKeys(ListApiKeys),
@@ -80,9 +87,6 @@ enum ReadRequest {
GetUserGroup(GetUserGroup),
ListUserGroups(ListUserGroups),
// ==== SEARCH ====
FindResources(FindResources),
// ==== PROCEDURE ====
GetProceduresSummary(GetProceduresSummary),
GetProcedure(GetProcedure),
@@ -90,29 +94,62 @@ enum ReadRequest {
ListProcedures(ListProcedures),
ListFullProcedures(ListFullProcedures),
// ==== SERVER TEMPLATE ====
GetServerTemplate(GetServerTemplate),
GetServerTemplatesSummary(GetServerTemplatesSummary),
ListServerTemplates(ListServerTemplates),
ListFullServerTemplates(ListFullServerTemplates),
// ==== ACTION ====
GetActionsSummary(GetActionsSummary),
GetAction(GetAction),
GetActionActionState(GetActionActionState),
ListActions(ListActions),
ListFullActions(ListFullActions),
// ==== SCHEDULE ====
ListSchedules(ListSchedules),
// ==== SERVER ====
GetServersSummary(GetServersSummary),
GetServer(GetServer),
GetServerState(GetServerState),
GetPeripheryVersion(GetPeripheryVersion),
GetPeripheryInformation(GetPeripheryInformation),
GetServerActionState(GetServerActionState),
GetHistoricalServerStats(GetHistoricalServerStats),
ListServers(ListServers),
ListFullServers(ListFullServers),
#[to_string_resolver]
ListTerminals(ListTerminals),
ListAllTerminals(ListAllTerminals),
// ==== DOCKER ====
GetDockerContainersSummary(GetDockerContainersSummary),
ListAllDockerContainers(ListAllDockerContainers),
ListDockerContainers(ListDockerContainers),
#[to_string_resolver]
ListDockerNetworks(ListDockerNetworks),
#[to_string_resolver]
ListDockerImages(ListDockerImages),
#[to_string_resolver]
InspectDockerContainer(InspectDockerContainer),
GetResourceMatchingContainer(GetResourceMatchingContainer),
GetContainerLog(GetContainerLog),
SearchContainerLog(SearchContainerLog),
ListComposeProjects(ListComposeProjects),
ListDockerNetworks(ListDockerNetworks),
InspectDockerNetwork(InspectDockerNetwork),
ListDockerImages(ListDockerImages),
InspectDockerImage(InspectDockerImage),
ListDockerImageHistory(ListDockerImageHistory),
ListDockerVolumes(ListDockerVolumes),
InspectDockerVolume(InspectDockerVolume),
// ==== SERVER STATS ====
GetSystemInformation(GetSystemInformation),
GetSystemStats(GetSystemStats),
ListSystemProcesses(ListSystemProcesses),
// ==== STACK ====
GetStacksSummary(GetStacksSummary),
GetStack(GetStack),
GetStackActionState(GetStackActionState),
GetStackLog(GetStackLog),
SearchStackLog(SearchStackLog),
InspectStackContainer(InspectStackContainer),
ListStacks(ListStacks),
ListFullStacks(ListFullStacks),
ListStackServices(ListStackServices),
ListCommonStackExtraArgs(ListCommonStackExtraArgs),
ListCommonStackBuildExtraArgs(ListCommonStackBuildExtraArgs),
// ==== DEPLOYMENT ====
GetDeploymentsSummary(GetDeploymentsSummary),
@@ -120,8 +157,9 @@ enum ReadRequest {
GetDeploymentContainer(GetDeploymentContainer),
GetDeploymentActionState(GetDeploymentActionState),
GetDeploymentStats(GetDeploymentStats),
GetLog(GetLog),
SearchLog(SearchLog),
GetDeploymentLog(GetDeploymentLog),
SearchDeploymentLog(SearchDeploymentLog),
InspectDeploymentContainer(InspectDeploymentContainer),
ListDeployments(ListDeployments),
ListFullDeployments(ListFullDeployments),
ListCommonDeploymentExtraArgs(ListCommonDeploymentExtraArgs),
@@ -132,7 +170,6 @@ enum ReadRequest {
GetBuildActionState(GetBuildActionState),
GetBuildMonthlyStats(GetBuildMonthlyStats),
ListBuildVersions(ListBuildVersions),
GetBuildWebhookEnabled(GetBuildWebhookEnabled),
ListBuilds(ListBuilds),
ListFullBuilds(ListFullBuilds),
ListCommonBuildExtraArgs(ListCommonBuildExtraArgs),
@@ -141,7 +178,6 @@ enum ReadRequest {
GetReposSummary(GetReposSummary),
GetRepo(GetRepo),
GetRepoActionState(GetRepoActionState),
GetRepoWebhooksEnabled(GetRepoWebhooksEnabled),
ListRepos(ListRepos),
ListFullRepos(ListFullRepos),
@@ -149,22 +185,9 @@ enum ReadRequest {
GetResourceSyncsSummary(GetResourceSyncsSummary),
GetResourceSync(GetResourceSync),
GetResourceSyncActionState(GetResourceSyncActionState),
GetSyncWebhooksEnabled(GetSyncWebhooksEnabled),
ListResourceSyncs(ListResourceSyncs),
ListFullResourceSyncs(ListFullResourceSyncs),
// ==== STACK ====
GetStacksSummary(GetStacksSummary),
GetStack(GetStack),
GetStackActionState(GetStackActionState),
GetStackWebhooksEnabled(GetStackWebhooksEnabled),
GetStackServiceLog(GetStackServiceLog),
SearchStackServiceLog(SearchStackServiceLog),
ListStacks(ListStacks),
ListFullStacks(ListFullStacks),
ListStackServices(ListStackServices),
ListCommonStackExtraArgs(ListCommonStackExtraArgs),
// ==== BUILDER ====
GetBuildersSummary(GetBuildersSummary),
GetBuilder(GetBuilder),
@@ -193,14 +216,6 @@ enum ReadRequest {
ListAlerts(ListAlerts),
GetAlert(GetAlert),
// ==== SERVER STATS ====
#[to_string_resolver]
GetSystemInformation(GetSystemInformation),
#[to_string_resolver]
GetSystemStats(GetSystemStats),
#[to_string_resolver]
ListSystemProcesses(ListSystemProcesses),
// ==== VARIABLE ====
GetVariable(GetVariable),
ListVariables(ListVariables),
@@ -210,139 +225,101 @@ enum ReadRequest {
ListGitProviderAccounts(ListGitProviderAccounts),
GetDockerRegistryAccount(GetDockerRegistryAccount),
ListDockerRegistryAccounts(ListDockerRegistryAccounts),
// ==== ONBOARDING KEY ====
ListOnboardingKeys(ListOnboardingKeys),
}
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
#[instrument(name = "ReadHandler", level = "debug", skip(user), fields(user_id = user.id))]
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: ReadRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
async fn handler(
Extension(user): Extension<User>,
Json(request): Json<ReadRequest>,
) -> serror::Result<(TypedHeader<ContentType>, String)> {
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!("/read request | user: {}", user.username);
let res =
State
.resolve_request(request, user)
.await
.map_err(|e| match e {
resolver_api::Error::Serialization(e) => {
anyhow!("{e:?}").context("response serialization error")
}
resolver_api::Error::Inner(e) => e,
});
let res = request.resolve(&ReadArgs { user }).await;
if let Err(e) = &res {
debug!("/read request {req_id} error: {e:#}");
debug!("/read request {req_id} error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/read request {req_id} | resolve time: {elapsed:?}");
Ok((TypedHeader(ContentType::json()), res?))
res.map(|res| res.0)
}
fn version() -> &'static String {
static VERSION: OnceLock<String> = OnceLock::new();
VERSION.get_or_init(|| {
serde_json::to_string(&GetVersionResponse {
impl Resolve<ReadArgs> for GetVersion {
async fn resolve(
self,
_: &ReadArgs,
) -> serror::Result<GetVersionResponse> {
Ok(GetVersionResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
})
.context("failed to serialize GetVersionResponse")
.unwrap()
})
}
impl ResolveToString<GetVersion, User> for State {
async fn resolve_to_string(
&self,
GetVersion {}: GetVersion,
_: User,
) -> anyhow::Result<String> {
Ok(version().to_string())
}
}
fn core_info() -> &'static String {
static CORE_INFO: OnceLock<String> = OnceLock::new();
CORE_INFO.get_or_init(|| {
impl Resolve<ReadArgs> for GetCoreInfo {
async fn resolve(
self,
_: &ReadArgs,
) -> serror::Result<GetCoreInfoResponse> {
let config = core_config();
let info = GetCoreInfoResponse {
title: config.title.clone(),
monitoring_interval: config.monitoring_interval,
webhook_base_url: config
.webhook_base_url
.clone()
.unwrap_or_else(|| config.host.clone()),
webhook_base_url: if config.webhook_base_url.is_empty() {
config.host.clone()
} else {
config.webhook_base_url.clone()
},
transparent_mode: config.transparent_mode,
ui_write_disabled: config.ui_write_disabled,
github_webhook_owners: config
.github_webhook_app
.installations
.iter()
.map(|i| i.namespace.to_string())
.collect(),
disable_confirm_dialog: config.disable_confirm_dialog,
disable_non_admin_create: config.disable_non_admin_create,
disable_websocket_reconnect: config.disable_websocket_reconnect,
enable_fancy_toml: config.enable_fancy_toml,
timezone: config.timezone.clone(),
public_key: core_keys().load().public.to_string(),
};
serde_json::to_string(&info)
.context("failed to serialize GetCoreInfoResponse")
.unwrap()
})
}
impl ResolveToString<GetCoreInfo, User> for State {
async fn resolve_to_string(
&self,
GetCoreInfo {}: GetCoreInfo,
_: User,
) -> anyhow::Result<String> {
Ok(core_info().to_string())
Ok(info)
}
}
fn ecr_labels() -> &'static String {
static ECR_LABELS: OnceLock<String> = OnceLock::new();
ECR_LABELS.get_or_init(|| {
serde_json::to_string(
&core_config()
.aws_ecr_registries
.iter()
.map(|reg| reg.label.clone())
.collect::<Vec<_>>(),
)
.context("failed to serialize ecr registries")
.unwrap()
})
}
impl ResolveToString<ListAwsEcrLabels, User> for State {
async fn resolve_to_string(
&self,
ListAwsEcrLabels {}: ListAwsEcrLabels,
_: User,
) -> anyhow::Result<String> {
Ok(ecr_labels().to_string())
}
}
impl Resolve<ListSecrets, User> for State {
impl Resolve<ReadArgs> for ListSecrets {
async fn resolve(
&self,
ListSecrets { target }: ListSecrets,
_: User,
) -> anyhow::Result<ListSecretsResponse> {
self,
_: &ReadArgs,
) -> serror::Result<ListSecretsResponse> {
let mut secrets = core_config()
.secrets
.keys()
.cloned()
.collect::<HashSet<_>>();
if let Some(target) = target {
if let Some(target) = self.target {
let server_id = match target {
ResourceTarget::Server(id) => Some(id),
ResourceTarget::Builder(id) => {
match resource::get::<Builder>(&id).await?.config {
BuilderConfig::Url(_) => None,
BuilderConfig::Server(config) => Some(config.server_id),
BuilderConfig::Aws(config) => {
secrets.extend(config.secrets);
@@ -351,12 +328,15 @@ impl Resolve<ListSecrets, User> for State {
}
}
_ => {
return Err(anyhow!("target must be `Server` or `Builder`"))
return Err(
anyhow!("target must be `Server` or `Builder`").into(),
);
}
};
if let Some(id) = server_id {
let server = resource::get::<Server>(&id).await?;
let more = periphery_client(&server)?
let more = periphery_client(&server)
.await?
.request(periphery_client::api::ListSecrets {})
.await
.with_context(|| {
@@ -376,21 +356,21 @@ impl Resolve<ListSecrets, User> for State {
}
}
impl Resolve<ListGitProvidersFromConfig, User> for State {
impl Resolve<ReadArgs> for ListGitProvidersFromConfig {
async fn resolve(
&self,
ListGitProvidersFromConfig { target }: ListGitProvidersFromConfig,
user: User,
) -> anyhow::Result<ListGitProvidersFromConfigResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListGitProvidersFromConfigResponse> {
let mut providers = core_config().git_providers.clone();
if let Some(target) = target {
if let Some(target) = self.target {
match target {
ResourceTarget::Server(id) => {
merge_git_providers_for_server(&mut providers, &id).await?;
}
ResourceTarget::Builder(id) => {
match resource::get::<Builder>(&id).await?.config {
BuilderConfig::Url(_) => {}
BuilderConfig::Server(config) => {
merge_git_providers_for_server(
&mut providers,
@@ -407,7 +387,9 @@ impl Resolve<ListGitProvidersFromConfig, User> for State {
}
}
_ => {
return Err(anyhow!("target must be `Server` or `Builder`"))
return Err(
anyhow!("target must be `Server` or `Builder`").into(),
);
}
}
}
@@ -415,12 +397,18 @@ impl Resolve<ListGitProvidersFromConfig, User> for State {
let (builds, repos, syncs) = tokio::try_join!(
resource::list_full_for_user::<Build>(
Default::default(),
&user
user,
&[]
),
resource::list_full_for_user::<Repo>(
Default::default(),
user,
&[]
),
resource::list_full_for_user::<Repo>(Default::default(), &user),
resource::list_full_for_user::<ResourceSync>(
Default::default(),
&user
user,
&[]
),
)?;
@@ -467,15 +455,14 @@ impl Resolve<ListGitProvidersFromConfig, User> for State {
}
}
impl Resolve<ListDockerRegistriesFromConfig, User> for State {
impl Resolve<ReadArgs> for ListDockerRegistriesFromConfig {
async fn resolve(
&self,
ListDockerRegistriesFromConfig { target }: ListDockerRegistriesFromConfig,
_: User,
) -> anyhow::Result<ListDockerRegistriesFromConfigResponse> {
self,
_: &ReadArgs,
) -> serror::Result<ListDockerRegistriesFromConfigResponse> {
let mut registries = core_config().docker_registries.clone();
if let Some(target) = target {
if let Some(target) = self.target {
match target {
ResourceTarget::Server(id) => {
merge_docker_registries_for_server(&mut registries, &id)
@@ -483,6 +470,7 @@ impl Resolve<ListDockerRegistriesFromConfig, User> for State {
}
ResourceTarget::Builder(id) => {
match resource::get::<Builder>(&id).await?.config {
BuilderConfig::Url(_) => {}
BuilderConfig::Server(config) => {
merge_docker_registries_for_server(
&mut registries,
@@ -499,7 +487,9 @@ impl Resolve<ListDockerRegistriesFromConfig, User> for State {
}
}
_ => {
return Err(anyhow!("target must be `Server` or `Builder`"))
return Err(
anyhow!("target must be `Server` or `Builder`").into(),
);
}
}
}
@@ -513,9 +503,10 @@ impl Resolve<ListDockerRegistriesFromConfig, User> for State {
async fn merge_git_providers_for_server(
providers: &mut Vec<GitProvider>,
server_id: &str,
) -> anyhow::Result<()> {
) -> serror::Result<()> {
let server = resource::get::<Server>(server_id).await?;
let more = periphery_client(&server)?
let more = periphery_client(&server)
.await?
.request(periphery_client::api::ListGitProviders {})
.await
.with_context(|| {
@@ -551,9 +542,10 @@ fn merge_git_providers(
async fn merge_docker_registries_for_server(
registries: &mut Vec<DockerRegistry>,
server_id: &str,
) -> anyhow::Result<()> {
) -> serror::Result<()> {
let server = resource::get::<Server>(server_id).await?;
let more = periphery_client(&server)?
let more = periphery_client(&server)
.await?
.request(periphery_client::api::ListDockerRegistries {})
.await
.with_context(|| {

View File

@@ -0,0 +1,51 @@
use std::cmp::Ordering;
use anyhow::{Context, anyhow};
use database::mungos::find::find_collect;
use komodo_client::api::read::{
ListOnboardingKeys, ListOnboardingKeysResponse,
};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{api::read::ReadArgs, state::db_client};
//
impl Resolve<ReadArgs> for ListOnboardingKeys {
async fn resolve(
self,
ReadArgs { user: admin }: &ReadArgs,
) -> serror::Result<ListOnboardingKeysResponse> {
if !admin.admin {
return Err(
anyhow!("This call is admin only")
.status_code(StatusCode::FORBIDDEN),
);
}
let mut keys =
find_collect(&db_client().onboarding_keys, None, None)
.await
.context(
"Failed to query database for Server onboarding keys",
)?;
// No expiry keys first, followed
keys.sort_by(|a, b| {
if a.expires == b.expires {
Ordering::Equal
} else if a.expires == 0 {
Ordering::Less
} else if b.expires == 0 {
Ordering::Greater
} else {
// Descending
b.expires.cmp(&a.expires)
}
});
Ok(keys)
}
}

View File

@@ -1,28 +1,28 @@
use anyhow::{anyhow, Context};
use monitor_client::{
use anyhow::{Context, anyhow};
use database::mungos::{find::find_collect, mongodb::bson::doc};
use komodo_client::{
api::read::{
GetPermissionLevel, GetPermissionLevelResponse, ListPermissions,
GetPermission, GetPermissionResponse, ListPermissions,
ListPermissionsResponse, ListUserTargetPermissions,
ListUserTargetPermissionsResponse,
},
entities::{permission::PermissionLevel, user::User},
entities::permission::PermissionLevel,
};
use mungos::{find::find_collect, mongodb::bson::doc};
use resolver_api::Resolve;
use crate::{
helpers::query::get_user_permission_on_target,
state::{db_client, State},
helpers::query::get_user_permission_on_target, state::db_client,
};
impl Resolve<ListPermissions, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for ListPermissions {
async fn resolve(
&self,
ListPermissions {}: ListPermissions,
user: User,
) -> anyhow::Result<ListPermissionsResponse> {
find_collect(
&db_client().await.permissions,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListPermissionsResponse> {
let res = find_collect(
&db_client().permissions,
doc! {
"user_target.type": "User",
"user_target.id": &user.id
@@ -30,35 +30,34 @@ impl Resolve<ListPermissions, User> for State {
None,
)
.await
.context("failed to query db for permissions")
.context("failed to query db for permissions")?;
Ok(res)
}
}
impl Resolve<GetPermissionLevel, User> for State {
impl Resolve<ReadArgs> for GetPermission {
async fn resolve(
&self,
GetPermissionLevel { target }: GetPermissionLevel,
user: User,
) -> anyhow::Result<GetPermissionLevelResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetPermissionResponse> {
if user.admin {
return Ok(PermissionLevel::Write);
return Ok(PermissionLevel::Write.all());
}
get_user_permission_on_target(&user, &target).await
Ok(get_user_permission_on_target(user, &self.target).await?)
}
}
impl Resolve<ListUserTargetPermissions, User> for State {
impl Resolve<ReadArgs> for ListUserTargetPermissions {
async fn resolve(
&self,
ListUserTargetPermissions { user_target }: ListUserTargetPermissions,
user: User,
) -> anyhow::Result<ListUserTargetPermissionsResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListUserTargetPermissionsResponse> {
if !user.admin {
return Err(anyhow!("this method is admin only"));
return Err(anyhow!("this method is admin only").into());
}
let (variant, id) = user_target.extract_variant_id();
find_collect(
&db_client().await.permissions,
let (variant, id) = self.user_target.extract_variant_id();
let res = find_collect(
&db_client().permissions,
doc! {
"user_target.type": variant.as_ref(),
"user_target.id": id
@@ -66,6 +65,7 @@ impl Resolve<ListUserTargetPermissions, User> for State {
None,
)
.await
.context("failed to query db for permissions")
.context("failed to query db for permissions")?;
Ok(res)
}
}

View File

@@ -1,63 +1,85 @@
use anyhow::Context;
use monitor_client::{
use komodo_client::{
api::read::*,
entities::{
permission::PermissionLevel,
procedure::{Procedure, ProcedureState},
user::User,
},
};
use resolver_api::Resolve;
use crate::{
helpers::query::get_all_tags,
permission::get_check_permissions,
resource,
state::{action_states, procedure_state_cache, State},
state::{action_states, procedure_state_cache},
};
impl Resolve<GetProcedure, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetProcedure {
async fn resolve(
&self,
GetProcedure { procedure }: GetProcedure,
user: User,
) -> anyhow::Result<GetProcedureResponse> {
resource::get_check_permissions::<Procedure>(
&procedure,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetProcedureResponse> {
Ok(
get_check_permissions::<Procedure>(
&self.procedure,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListProcedures, User> for State {
impl Resolve<ReadArgs> for ListProcedures {
async fn resolve(
&self,
ListProcedures { query }: ListProcedures,
user: User,
) -> anyhow::Result<ListProceduresResponse> {
resource::list_for_user::<Procedure>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListProceduresResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<Procedure>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<ListFullProcedures, User> for State {
impl Resolve<ReadArgs> for ListFullProcedures {
async fn resolve(
&self,
ListFullProcedures { query }: ListFullProcedures,
user: User,
) -> anyhow::Result<ListFullProceduresResponse> {
resource::list_full_for_user::<Procedure>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullProceduresResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Procedure>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetProceduresSummary, User> for State {
impl Resolve<ReadArgs> for GetProceduresSummary {
async fn resolve(
&self,
GetProceduresSummary {}: GetProceduresSummary,
user: User,
) -> anyhow::Result<GetProceduresSummaryResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetProceduresSummaryResponse> {
let procedures = resource::list_full_for_user::<Procedure>(
Default::default(),
&user,
user,
&[],
)
.await
.context("failed to get procedures from db")?;
@@ -94,16 +116,15 @@ impl Resolve<GetProceduresSummary, User> for State {
}
}
impl Resolve<GetProcedureActionState, User> for State {
impl Resolve<ReadArgs> for GetProcedureActionState {
async fn resolve(
&self,
GetProcedureActionState { procedure }: GetProcedureActionState,
user: User,
) -> anyhow::Result<GetProcedureActionStateResponse> {
let procedure = resource::get_check_permissions::<Procedure>(
&procedure,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetProcedureActionStateResponse> {
let procedure = get_check_permissions::<Procedure>(
&self.procedure,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()

View File

@@ -1,116 +1,115 @@
use anyhow::{anyhow, Context};
use mongo_indexed::{doc, Document};
use monitor_client::{
api::read::{
GetDockerRegistryAccount, GetDockerRegistryAccountResponse,
GetGitProviderAccount, GetGitProviderAccountResponse,
ListDockerRegistryAccounts, ListDockerRegistryAccountsResponse,
ListGitProviderAccounts, ListGitProviderAccountsResponse,
},
entities::user::User,
};
use mungos::{
use anyhow::{Context, anyhow};
use database::mongo_indexed::{Document, doc};
use database::mungos::{
by_id::find_one_by_id, find::find_collect,
mongodb::options::FindOptions,
};
use komodo_client::api::read::*;
use resolver_api::Resolve;
use crate::state::{db_client, State};
use crate::state::db_client;
impl Resolve<GetGitProviderAccount, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetGitProviderAccount {
async fn resolve(
&self,
GetGitProviderAccount { id }: GetGitProviderAccount,
user: User,
) -> anyhow::Result<GetGitProviderAccountResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetGitProviderAccountResponse> {
if !user.admin {
return Err(anyhow!(
"Only admins can read git provider accounts"
));
return Err(
anyhow!("Only admins can read git provider accounts").into(),
);
}
find_one_by_id(&db_client().await.git_accounts, &id)
let res = find_one_by_id(&db_client().git_accounts, &self.id)
.await
.context("failed to query db for git provider accounts")?
.context("did not find git provider account with the given id")
}
}
impl Resolve<ListGitProviderAccounts, User> for State {
async fn resolve(
&self,
ListGitProviderAccounts { domain, username }: ListGitProviderAccounts,
user: User,
) -> anyhow::Result<ListGitProviderAccountsResponse> {
if !user.admin {
return Err(anyhow!(
"Only admins can read git provider accounts"
));
}
let mut filter = Document::new();
if let Some(domain) = domain {
filter.insert("domain", domain);
}
if let Some(username) = username {
filter.insert("username", username);
}
find_collect(
&db_client().await.git_accounts,
filter,
FindOptions::builder()
.sort(doc! { "domain": 1, "username": 1 })
.build(),
)
.await
.context("failed to query db for git provider accounts")
}
}
impl Resolve<GetDockerRegistryAccount, User> for State {
async fn resolve(
&self,
GetDockerRegistryAccount { id }: GetDockerRegistryAccount,
user: User,
) -> anyhow::Result<GetDockerRegistryAccountResponse> {
if !user.admin {
return Err(anyhow!(
"Only admins can read docker registry accounts"
));
}
find_one_by_id(&db_client().await.registry_accounts, &id)
.await
.context("failed to query db for docker registry accounts")?
.context(
"did not find docker registry account with the given id",
)
"did not find git provider account with the given id",
)?;
Ok(res)
}
}
impl Resolve<ListDockerRegistryAccounts, User> for State {
impl Resolve<ReadArgs> for ListGitProviderAccounts {
async fn resolve(
&self,
ListDockerRegistryAccounts { domain, username }: ListDockerRegistryAccounts,
user: User,
) -> anyhow::Result<ListDockerRegistryAccountsResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListGitProviderAccountsResponse> {
if !user.admin {
return Err(anyhow!(
"Only admins can read docker registry accounts"
));
return Err(
anyhow!("Only admins can read git provider accounts").into(),
);
}
let mut filter = Document::new();
if let Some(domain) = domain {
if let Some(domain) = self.domain {
filter.insert("domain", domain);
}
if let Some(username) = username {
if let Some(username) = self.username {
filter.insert("username", username);
}
find_collect(
&db_client().await.registry_accounts,
let res = find_collect(
&db_client().git_accounts,
filter,
FindOptions::builder()
.sort(doc! { "domain": 1, "username": 1 })
.build(),
)
.await
.context("failed to query db for docker registry accounts")
.context("failed to query db for git provider accounts")?;
Ok(res)
}
}
impl Resolve<ReadArgs> for GetDockerRegistryAccount {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetDockerRegistryAccountResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can read docker registry accounts")
.into(),
);
}
let res =
find_one_by_id(&db_client().registry_accounts, &self.id)
.await
.context("failed to query db for docker registry accounts")?
.context(
"did not find docker registry account with the given id",
)?;
Ok(res)
}
}
impl Resolve<ReadArgs> for ListDockerRegistryAccounts {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListDockerRegistryAccountsResponse> {
if !user.admin {
return Err(
anyhow!("Only admins can read docker registry accounts")
.into(),
);
}
let mut filter = Document::new();
if let Some(domain) = self.domain {
filter.insert("domain", domain);
}
if let Some(username) = self.username {
filter.insert("username", username);
}
let res = find_collect(
&db_client().registry_accounts,
filter,
FindOptions::builder()
.sort(doc! { "domain": 1, "username": 1 })
.build(),
)
.await
.context("failed to query db for docker registry accounts")?;
Ok(res)
}
}

View File

@@ -1,66 +1,83 @@
use anyhow::Context;
use monitor_client::{
use komodo_client::{
api::read::*,
entities::{
config::core::CoreConfig,
permission::PermissionLevel,
repo::{Repo, RepoActionState, RepoListItem, RepoState},
user::User,
},
};
use resolver_api::Resolve;
use crate::{
config::core_config,
helpers::query::get_all_tags,
permission::get_check_permissions,
resource,
state::{action_states, github_client, repo_state_cache, State},
state::{action_states, repo_state_cache},
};
impl Resolve<GetRepo, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetRepo {
async fn resolve(
&self,
GetRepo { repo }: GetRepo,
user: User,
) -> anyhow::Result<Repo> {
resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Repo> {
Ok(
get_check_permissions::<Repo>(
&self.repo,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListRepos, User> for State {
impl Resolve<ReadArgs> for ListRepos {
async fn resolve(
&self,
ListRepos { query }: ListRepos,
user: User,
) -> anyhow::Result<Vec<RepoListItem>> {
resource::list_for_user::<Repo>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<RepoListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<Repo>(self.query, user, &all_tags)
.await?,
)
}
}
impl Resolve<ListFullRepos, User> for State {
impl Resolve<ReadArgs> for ListFullRepos {
async fn resolve(
&self,
ListFullRepos { query }: ListFullRepos,
user: User,
) -> anyhow::Result<ListFullReposResponse> {
resource::list_full_for_user::<Repo>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullReposResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Repo>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetRepoActionState, User> for State {
impl Resolve<ReadArgs> for GetRepoActionState {
async fn resolve(
&self,
GetRepoActionState { repo }: GetRepoActionState,
user: User,
) -> anyhow::Result<RepoActionState> {
let repo = resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<RepoActionState> {
let repo = get_check_permissions::<Repo>(
&self.repo,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()
@@ -73,16 +90,18 @@ impl Resolve<GetRepoActionState, User> for State {
}
}
impl Resolve<GetReposSummary, User> for State {
impl Resolve<ReadArgs> for GetReposSummary {
async fn resolve(
&self,
GetReposSummary {}: GetReposSummary,
user: User,
) -> anyhow::Result<GetReposSummaryResponse> {
let repos =
resource::list_full_for_user::<Repo>(Default::default(), &user)
.await
.context("failed to get repos from db")?;
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetReposSummaryResponse> {
let repos = resource::list_full_for_user::<Repo>(
Default::default(),
user,
&[],
)
.await
.context("failed to get repos from db")?;
let mut res = GetReposSummaryResponse::default();
@@ -112,7 +131,11 @@ impl Resolve<GetReposSummary, User> for State {
}
(RepoState::Ok, _) => res.ok += 1,
(RepoState::Failed, _) => res.failed += 1,
(RepoState::Unknown, _) => res.unknown += 1,
(RepoState::Unknown, _) => {
if !repo.template {
res.unknown += 1
}
}
// will never come off the cache in the building state, since that comes from action states
(RepoState::Cloning, _)
| (RepoState::Pulling, _)
@@ -125,101 +148,3 @@ impl Resolve<GetReposSummary, User> for State {
Ok(res)
}
}
impl Resolve<GetRepoWebhooksEnabled, User> for State {
async fn resolve(
&self,
GetRepoWebhooksEnabled { repo }: GetRepoWebhooksEnabled,
user: User,
) -> anyhow::Result<GetRepoWebhooksEnabledResponse> {
let Some(github) = github_client() else {
return Ok(GetRepoWebhooksEnabledResponse {
managed: false,
clone_enabled: false,
pull_enabled: false,
build_enabled: false,
});
};
let repo = resource::get_check_permissions::<Repo>(
&repo,
&user,
PermissionLevel::Read,
)
.await?;
if repo.config.git_provider != "github.com"
|| repo.config.repo.is_empty()
{
return Ok(GetRepoWebhooksEnabledResponse {
managed: false,
clone_enabled: false,
pull_enabled: false,
build_enabled: false,
});
}
let mut split = repo.config.repo.split('/');
let owner = split.next().context("Repo repo has no owner")?;
let Some(github) = github.get(owner) else {
return Ok(GetRepoWebhooksEnabledResponse {
managed: false,
clone_enabled: false,
pull_enabled: false,
build_enabled: false,
});
};
let repo_name =
split.next().context("Repo repo has no repo after the /")?;
let github_repos = github.repos();
let webhooks = github_repos
.list_all_webhooks(owner, repo_name)
.await
.context("failed to list all webhooks on repo")?
.body;
let CoreConfig {
host,
webhook_base_url,
..
} = core_config();
let host = webhook_base_url.as_ref().unwrap_or(host);
let clone_url =
format!("{host}/listener/github/repo/{}/clone", repo.id);
let pull_url =
format!("{host}/listener/github/repo/{}/pull", repo.id);
let build_url =
format!("{host}/listener/github/repo/{}/build", repo.id);
let mut clone_enabled = false;
let mut pull_enabled = false;
let mut build_enabled = false;
for webhook in webhooks {
if !webhook.active {
continue;
}
if webhook.config.url == clone_url {
clone_enabled = true
}
if webhook.config.url == pull_url {
pull_enabled = true
}
if webhook.config.url == build_url {
build_enabled = true
}
}
Ok(GetRepoWebhooksEnabledResponse {
managed: true,
clone_enabled,
pull_enabled,
build_enabled,
})
}
}

View File

@@ -0,0 +1,104 @@
use futures_util::future::join_all;
use komodo_client::{
api::read::*,
entities::{
ResourceTarget,
action::Action,
procedure::Procedure,
resource::{ResourceQuery, TemplatesQueryBehavior},
schedule::Schedule,
},
};
use resolver_api::Resolve;
use crate::{
helpers::query::{get_all_tags, get_last_run_at},
resource::list_full_for_user,
schedule::get_schedule_item_info,
};
use super::ReadArgs;
impl Resolve<ReadArgs> for ListSchedules {
async fn resolve(
self,
args: &ReadArgs,
) -> serror::Result<Vec<Schedule>> {
let all_tags = get_all_tags(None).await?;
let (actions, procedures) = tokio::try_join!(
list_full_for_user::<Action>(
ResourceQuery {
names: Default::default(),
templates: TemplatesQueryBehavior::Include,
tag_behavior: self.tag_behavior,
tags: self.tags.clone(),
specific: Default::default(),
},
&args.user,
&all_tags,
),
list_full_for_user::<Procedure>(
ResourceQuery {
names: Default::default(),
templates: TemplatesQueryBehavior::Include,
tag_behavior: self.tag_behavior,
tags: self.tags.clone(),
specific: Default::default(),
},
&args.user,
&all_tags,
)
)?;
let actions = actions.into_iter().map(async |action| {
let (next_scheduled_run, schedule_error) =
get_schedule_item_info(&ResourceTarget::Action(
action.id.clone(),
));
let last_run_at =
get_last_run_at::<Action>(&action.id).await.unwrap_or(None);
Schedule {
target: ResourceTarget::Action(action.id),
name: action.name,
enabled: action.config.schedule_enabled,
schedule_format: action.config.schedule_format,
schedule: action.config.schedule,
schedule_timezone: action.config.schedule_timezone,
tags: action.tags,
last_run_at,
next_scheduled_run,
schedule_error,
}
});
let procedures = procedures.into_iter().map(async |procedure| {
let (next_scheduled_run, schedule_error) =
get_schedule_item_info(&ResourceTarget::Procedure(
procedure.id.clone(),
));
let last_run_at = get_last_run_at::<Procedure>(&procedure.id)
.await
.unwrap_or(None);
Schedule {
target: ResourceTarget::Procedure(procedure.id),
name: procedure.name,
enabled: procedure.config.schedule_enabled,
schedule_format: procedure.config.schedule_format,
schedule: procedure.config.schedule,
schedule_timezone: procedure.config.schedule_timezone,
tags: procedure.tags,
last_run_at,
next_scheduled_run,
schedule_error,
}
});
let (actions, procedures) =
tokio::join!(join_all(actions), join_all(procedures));
Ok(
actions
.into_iter()
.chain(procedures)
.filter(|s| !s.schedule.is_empty())
.collect(),
)
}
}

View File

@@ -1,83 +0,0 @@
use monitor_client::{
api::read::{FindResources, FindResourcesResponse},
entities::{
build::Build, deployment::Deployment, procedure::Procedure,
repo::Repo, server::Server, update::ResourceTargetVariant,
user::User,
},
};
use resolver_api::Resolve;
use crate::{resource, state::State};
const FIND_RESOURCE_TYPES: [ResourceTargetVariant; 5] = [
ResourceTargetVariant::Server,
ResourceTargetVariant::Build,
ResourceTargetVariant::Deployment,
ResourceTargetVariant::Repo,
ResourceTargetVariant::Procedure,
];
impl Resolve<FindResources, User> for State {
async fn resolve(
&self,
FindResources { query, resources }: FindResources,
user: User,
) -> anyhow::Result<FindResourcesResponse> {
let mut res = FindResourcesResponse::default();
let resource_types = if resources.is_empty() {
FIND_RESOURCE_TYPES.to_vec()
} else {
resources
.into_iter()
.filter(|r| {
!matches!(
r,
ResourceTargetVariant::System
| ResourceTargetVariant::Builder
| ResourceTargetVariant::Alerter
)
})
.collect()
};
for resource_type in resource_types {
match resource_type {
ResourceTargetVariant::Server => {
res.servers = resource::list_for_user_using_document::<
Server,
>(query.clone(), &user)
.await?;
}
ResourceTargetVariant::Deployment => {
res.deployments = resource::list_for_user_using_document::<
Deployment,
>(query.clone(), &user)
.await?;
}
ResourceTargetVariant::Build => {
res.builds =
resource::list_for_user_using_document::<Build>(
query.clone(),
&user,
)
.await?;
}
ResourceTargetVariant::Repo => {
res.repos = resource::list_for_user_using_document::<Repo>(
query.clone(),
&user,
)
.await?;
}
ResourceTargetVariant::Procedure => {
res.procedures = resource::list_for_user_using_document::<
Procedure,
>(query.clone(), &user)
.await?;
}
_ => {}
}
}
Ok(res)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +0,0 @@
use anyhow::Context;
use mongo_indexed::Document;
use monitor_client::{
api::read::*,
entities::{
permission::PermissionLevel, server_template::ServerTemplate,
user::User,
},
};
use mungos::mongodb::bson::doc;
use resolver_api::Resolve;
use crate::{
resource,
state::{db_client, State},
};
impl Resolve<GetServerTemplate, User> for State {
async fn resolve(
&self,
GetServerTemplate { server_template }: GetServerTemplate,
user: User,
) -> anyhow::Result<GetServerTemplateResponse> {
resource::get_check_permissions::<ServerTemplate>(
&server_template,
&user,
PermissionLevel::Read,
)
.await
}
}
impl Resolve<ListServerTemplates, User> for State {
async fn resolve(
&self,
ListServerTemplates { query }: ListServerTemplates,
user: User,
) -> anyhow::Result<ListServerTemplatesResponse> {
resource::list_for_user::<ServerTemplate>(query, &user).await
}
}
impl Resolve<ListFullServerTemplates, User> for State {
async fn resolve(
&self,
ListFullServerTemplates { query }: ListFullServerTemplates,
user: User,
) -> anyhow::Result<ListFullServerTemplatesResponse> {
resource::list_full_for_user::<ServerTemplate>(query, &user).await
}
}
impl Resolve<GetServerTemplatesSummary, User> for State {
async fn resolve(
&self,
GetServerTemplatesSummary {}: GetServerTemplatesSummary,
user: User,
) -> anyhow::Result<GetServerTemplatesSummaryResponse> {
let query = match resource::get_resource_ids_for_user::<
ServerTemplate,
>(&user)
.await?
{
Some(ids) => doc! {
"_id": { "$in": ids }
},
None => Document::new(),
};
let total = db_client()
.await
.server_templates
.count_documents(query)
.await
.context("failed to count all server template documents")?;
let res = GetServerTemplatesSummaryResponse {
total: total as u32,
};
Ok(res)
}
}

View File

@@ -1,52 +1,56 @@
use std::collections::HashSet;
use anyhow::Context;
use monitor_client::{
use anyhow::{Context, anyhow};
use komodo_client::{
api::read::*,
entities::{
config::core::CoreConfig,
docker::container::Container,
permission::PermissionLevel,
server::{Server, ServerState},
stack::{Stack, StackActionState, StackListItem, StackState},
user::User,
},
};
use periphery_client::api::compose::{
GetComposeServiceLog, GetComposeServiceLogSearch,
use periphery_client::api::{
compose::{GetComposeLog, GetComposeLogSearch},
container::InspectContainer,
};
use resolver_api::Resolve;
use crate::{
config::core_config,
helpers::{periphery_client, stack::get_stack_and_server},
helpers::{periphery_client, query::get_all_tags},
permission::get_check_permissions,
resource,
state::{action_states, github_client, stack_status_cache, State},
stack::get_stack_and_server,
state::{action_states, server_status_cache, stack_status_cache},
};
impl Resolve<GetStack, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetStack {
async fn resolve(
&self,
GetStack { stack }: GetStack,
user: User,
) -> anyhow::Result<Stack> {
resource::get_check_permissions::<Stack>(
&stack,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Stack> {
Ok(
get_check_permissions::<Stack>(
&self.stack,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListStackServices, User> for State {
impl Resolve<ReadArgs> for ListStackServices {
async fn resolve(
&self,
ListStackServices { stack }: ListStackServices,
user: User,
) -> anyhow::Result<ListStackServicesResponse> {
let stack = resource::get_check_permissions::<Stack>(
&stack,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListStackServicesResponse> {
let stack = get_check_permissions::<Stack>(
&self.stack,
user,
PermissionLevel::Read.into(),
)
.await?;
@@ -62,75 +66,144 @@ impl Resolve<ListStackServices, User> for State {
}
}
impl Resolve<GetStackServiceLog, User> for State {
impl Resolve<ReadArgs> for GetStackLog {
async fn resolve(
&self,
GetStackServiceLog {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetStackLogResponse> {
let GetStackLog {
stack,
service,
services,
tail,
}: GetStackServiceLog,
user: User,
) -> anyhow::Result<GetStackServiceLogResponse> {
timestamps,
} = self;
let (stack, server) = get_stack_and_server(
&stack,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.logs(),
true,
)
.await?;
periphery_client(&server)?
.request(GetComposeServiceLog {
let res = periphery_client(&server)
.await?
.request(GetComposeLog {
project: stack.project_name(false),
service,
services,
tail,
timestamps,
})
.await
.context("failed to get stack service log from periphery")
.context("Failed to get stack log from periphery")?;
Ok(res)
}
}
impl Resolve<SearchStackServiceLog, User> for State {
impl Resolve<ReadArgs> for SearchStackLog {
async fn resolve(
&self,
SearchStackServiceLog {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<SearchStackLogResponse> {
let SearchStackLog {
stack,
service,
services,
terms,
combinator,
invert,
}: SearchStackServiceLog,
user: User,
) -> anyhow::Result<SearchStackServiceLogResponse> {
timestamps,
} = self;
let (stack, server) = get_stack_and_server(
&stack,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.logs(),
true,
)
.await?;
periphery_client(&server)?
.request(GetComposeServiceLogSearch {
let res = periphery_client(&server)
.await?
.request(GetComposeLogSearch {
project: stack.project_name(false),
service,
services,
terms,
combinator,
invert,
timestamps,
})
.await
.context("failed to get stack service log from periphery")
.context("Failed to search stack log from periphery")?;
Ok(res)
}
}
impl Resolve<ListCommonStackExtraArgs, User> for State {
impl Resolve<ReadArgs> for InspectStackContainer {
async fn resolve(
&self,
ListCommonStackExtraArgs { query }: ListCommonStackExtraArgs,
user: User,
) -> anyhow::Result<ListCommonStackExtraArgsResponse> {
let stacks = resource::list_full_for_user::<Stack>(query, &user)
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Container> {
let InspectStackContainer { stack, service } = self;
let stack = get_check_permissions::<Stack>(
&stack,
user,
PermissionLevel::Read.inspect(),
)
.await?;
if stack.config.server_id.is_empty() {
return Err(
anyhow!("Cannot inspect stack, not attached to any server")
.into(),
);
}
let server =
resource::get::<Server>(&stack.config.server_id).await?;
let cache = server_status_cache()
.get_or_insert_default(&server.id)
.await;
if cache.state != ServerState::Ok {
return Err(
anyhow!(
"Cannot inspect container: server is {:?}",
cache.state
)
.into(),
);
}
let services = &stack_status_cache()
.get(&stack.id)
.await
.context("failed to get resources matching query")?;
.unwrap_or_default()
.curr
.services;
let Some(name) = services
.iter()
.find(|s| s.service == service)
.and_then(|s| s.container.as_ref().map(|c| c.name.clone()))
else {
return Err(anyhow!(
"No service found matching '{service}'. Was the stack last deployed manually?"
).into());
};
let res = periphery_client(&server)
.await?
.request(InspectContainer { name })
.await?;
Ok(res)
}
}
impl Resolve<ReadArgs> for ListCommonStackExtraArgs {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListCommonStackExtraArgsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
let stacks = resource::list_full_for_user::<Stack>(
self.query, user, &all_tags,
)
.await
.context("failed to get resources matching query")?;
// first collect with guaranteed uniqueness
let mut res = HashSet::<String>::new();
@@ -147,36 +220,97 @@ impl Resolve<ListCommonStackExtraArgs, User> for State {
}
}
impl Resolve<ListStacks, User> for State {
impl Resolve<ReadArgs> for ListCommonStackBuildExtraArgs {
async fn resolve(
&self,
ListStacks { query }: ListStacks,
user: User,
) -> anyhow::Result<Vec<StackListItem>> {
resource::list_for_user::<Stack>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListCommonStackBuildExtraArgsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
let stacks = resource::list_full_for_user::<Stack>(
self.query, user, &all_tags,
)
.await
.context("failed to get resources matching query")?;
// first collect with guaranteed uniqueness
let mut res = HashSet::<String>::new();
for stack in stacks {
for extra_arg in stack.config.build_extra_args {
res.insert(extra_arg);
}
}
let mut res = res.into_iter().collect::<Vec<_>>();
res.sort();
Ok(res)
}
}
impl Resolve<ListFullStacks, User> for State {
impl Resolve<ReadArgs> for ListStacks {
async fn resolve(
&self,
ListFullStacks { query }: ListFullStacks,
user: User,
) -> anyhow::Result<ListFullStacksResponse> {
resource::list_full_for_user::<Stack>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<StackListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
let only_update_available = self.query.specific.update_available;
let stacks =
resource::list_for_user::<Stack>(self.query, user, &all_tags)
.await?;
let stacks = if only_update_available {
stacks
.into_iter()
.filter(|stack| {
stack
.info
.services
.iter()
.any(|service| service.update_available)
})
.collect()
} else {
stacks
};
Ok(stacks)
}
}
impl Resolve<GetStackActionState, User> for State {
impl Resolve<ReadArgs> for ListFullStacks {
async fn resolve(
&self,
GetStackActionState { stack }: GetStackActionState,
user: User,
) -> anyhow::Result<StackActionState> {
let stack = resource::get_check_permissions::<Stack>(
&stack,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullStacksResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<Stack>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<ReadArgs> for GetStackActionState {
async fn resolve(
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<StackActionState> {
let stack = get_check_permissions::<Stack>(
&self.stack,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()
@@ -189,15 +323,15 @@ impl Resolve<GetStackActionState, User> for State {
}
}
impl Resolve<GetStacksSummary, User> for State {
impl Resolve<ReadArgs> for GetStacksSummary {
async fn resolve(
&self,
GetStacksSummary {}: GetStacksSummary,
user: User,
) -> anyhow::Result<GetStacksSummaryResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetStacksSummaryResponse> {
let stacks = resource::list_full_for_user::<Stack>(
Default::default(),
&user,
user,
&[],
)
.await
.context("failed to get stacks from db")?;
@@ -211,103 +345,17 @@ impl Resolve<GetStacksSummary, User> for State {
match cache.get(&stack.id).await.unwrap_or_default().curr.state
{
StackState::Running => res.running += 1,
StackState::Paused => res.paused += 1,
StackState::Stopped => res.stopped += 1,
StackState::Restarting => res.restarting += 1,
StackState::Created => res.created += 1,
StackState::Removing => res.removing += 1,
StackState::Dead => res.dead += 1,
StackState::Unhealthy => res.unhealthy += 1,
StackState::Stopped | StackState::Paused => res.stopped += 1,
StackState::Down => res.down += 1,
StackState::Unknown => res.unknown += 1,
StackState::Unknown => {
if !stack.template {
res.unknown += 1
}
}
_ => res.unhealthy += 1,
}
}
Ok(res)
}
}
impl Resolve<GetStackWebhooksEnabled, User> for State {
async fn resolve(
&self,
GetStackWebhooksEnabled { stack }: GetStackWebhooksEnabled,
user: User,
) -> anyhow::Result<GetStackWebhooksEnabledResponse> {
let Some(github) = github_client() else {
return Ok(GetStackWebhooksEnabledResponse {
managed: false,
refresh_enabled: false,
deploy_enabled: false,
});
};
let stack = resource::get_check_permissions::<Stack>(
&stack,
&user,
PermissionLevel::Read,
)
.await?;
if stack.config.git_provider != "github.com"
|| stack.config.repo.is_empty()
{
return Ok(GetStackWebhooksEnabledResponse {
managed: false,
refresh_enabled: false,
deploy_enabled: false,
});
}
let mut split = stack.config.repo.split('/');
let owner = split.next().context("Sync repo has no owner")?;
let Some(github) = github.get(owner) else {
return Ok(GetStackWebhooksEnabledResponse {
managed: false,
refresh_enabled: false,
deploy_enabled: false,
});
};
let repo_name =
split.next().context("Repo repo has no repo after the /")?;
let github_repos = github.repos();
let webhooks = github_repos
.list_all_webhooks(owner, repo_name)
.await
.context("failed to list all webhooks on repo")?
.body;
let CoreConfig {
host,
webhook_base_url,
..
} = core_config();
let host = webhook_base_url.as_ref().unwrap_or(host);
let refresh_url =
format!("{host}/listener/github/stack/{}/refresh", stack.id);
let deploy_url =
format!("{host}/listener/github/stack/{}/deploy", stack.id);
let mut refresh_enabled = false;
let mut deploy_enabled = false;
for webhook in webhooks {
if webhook.active && webhook.config.url == refresh_url {
refresh_enabled = true
}
if webhook.active && webhook.config.url == deploy_url {
deploy_enabled = true
}
}
Ok(GetStackWebhooksEnabledResponse {
managed: true,
refresh_enabled,
deploy_enabled,
})
}
}

View File

@@ -1,75 +1,89 @@
use anyhow::Context;
use monitor_client::{
use komodo_client::{
api::read::*,
entities::{
config::core::CoreConfig,
permission::PermissionLevel,
sync::{
PendingSyncUpdatesData, ResourceSync, ResourceSyncActionState,
ResourceSyncListItem, ResourceSyncState,
ResourceSync, ResourceSyncActionState, ResourceSyncListItem,
},
user::User,
},
};
use resolver_api::Resolve;
use crate::{
config::core_config,
resource,
state::{
action_states, github_client, resource_sync_state_cache, State,
},
helpers::query::get_all_tags, permission::get_check_permissions,
resource, state::action_states,
};
impl Resolve<GetResourceSync, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetResourceSync {
async fn resolve(
&self,
GetResourceSync { sync }: GetResourceSync,
user: User,
) -> anyhow::Result<ResourceSync> {
resource::get_check_permissions::<ResourceSync>(
&sync,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ResourceSync> {
Ok(
get_check_permissions::<ResourceSync>(
&self.sync,
user,
PermissionLevel::Read.into(),
)
.await?,
)
.await
}
}
impl Resolve<ListResourceSyncs, User> for State {
impl Resolve<ReadArgs> for ListResourceSyncs {
async fn resolve(
&self,
ListResourceSyncs { query }: ListResourceSyncs,
user: User,
) -> anyhow::Result<Vec<ResourceSyncListItem>> {
resource::list_for_user::<ResourceSync>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Vec<ResourceSyncListItem>> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_for_user::<ResourceSync>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<ListFullResourceSyncs, User> for State {
impl Resolve<ReadArgs> for ListFullResourceSyncs {
async fn resolve(
&self,
ListFullResourceSyncs { query }: ListFullResourceSyncs,
user: User,
) -> anyhow::Result<ListFullResourceSyncsResponse> {
resource::list_full_for_user::<ResourceSync>(query, &user).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListFullResourceSyncsResponse> {
let all_tags = if self.query.tags.is_empty() {
vec![]
} else {
get_all_tags(None).await?
};
Ok(
resource::list_full_for_user::<ResourceSync>(
self.query, user, &all_tags,
)
.await?,
)
}
}
impl Resolve<GetResourceSyncActionState, User> for State {
impl Resolve<ReadArgs> for GetResourceSyncActionState {
async fn resolve(
&self,
GetResourceSyncActionState { sync }: GetResourceSyncActionState,
user: User,
) -> anyhow::Result<ResourceSyncActionState> {
let sync = resource::get_check_permissions::<ResourceSync>(
&sync,
&user,
PermissionLevel::Read,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ResourceSyncActionState> {
let sync = get_check_permissions::<ResourceSync>(
&self.sync,
user,
PermissionLevel::Read.into(),
)
.await?;
let action_state = action_states()
.resource_sync
.sync
.get(&sync.id)
.await
.unwrap_or_default()
@@ -78,151 +92,54 @@ impl Resolve<GetResourceSyncActionState, User> for State {
}
}
impl Resolve<GetResourceSyncsSummary, User> for State {
impl Resolve<ReadArgs> for GetResourceSyncsSummary {
async fn resolve(
&self,
GetResourceSyncsSummary {}: GetResourceSyncsSummary,
user: User,
) -> anyhow::Result<GetResourceSyncsSummaryResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetResourceSyncsSummaryResponse> {
let resource_syncs =
resource::list_full_for_user::<ResourceSync>(
Default::default(),
&user,
user,
&[],
)
.await
.context("failed to get resource_syncs from db")?;
let mut res = GetResourceSyncsSummaryResponse::default();
let cache = resource_sync_state_cache();
let action_states = action_states();
for resource_sync in resource_syncs {
res.total += 1;
match resource_sync.info.pending.data {
PendingSyncUpdatesData::Ok(data) => {
if !data.no_updates() {
res.pending += 1;
continue;
}
}
PendingSyncUpdatesData::Err(_) => {
res.failed += 1;
continue;
}
if !(resource_sync.info.pending_deploy.to_deploy == 0
&& resource_sync.info.resource_updates.is_empty()
&& resource_sync.info.variable_updates.is_empty()
&& resource_sync.info.user_group_updates.is_empty())
{
res.pending += 1;
continue;
} else if resource_sync.info.pending_error.is_some()
|| !resource_sync.info.remote_errors.is_empty()
{
res.failed += 1;
continue;
}
match (
cache.get(&resource_sync.id).await.unwrap_or_default(),
action_states
.resource_sync
.get(&resource_sync.id)
.await
.unwrap_or_default()
.get()?,
) {
(_, action_states) if action_states.syncing => {
res.syncing += 1;
}
(ResourceSyncState::Ok, _) => res.ok += 1,
(ResourceSyncState::Failed, _) => res.failed += 1,
(ResourceSyncState::Unknown, _) => res.unknown += 1,
// will never come off the cache in the building state, since that comes from action states
(ResourceSyncState::Syncing, _) => {
unreachable!()
}
(ResourceSyncState::Pending, _) => {
unreachable!()
}
if action_states
.sync
.get(&resource_sync.id)
.await
.unwrap_or_default()
.get()?
.syncing
{
res.syncing += 1;
continue;
}
res.ok += 1;
}
Ok(res)
}
}
impl Resolve<GetSyncWebhooksEnabled, User> for State {
async fn resolve(
&self,
GetSyncWebhooksEnabled { sync }: GetSyncWebhooksEnabled,
user: User,
) -> anyhow::Result<GetSyncWebhooksEnabledResponse> {
let Some(github) = github_client() else {
return Ok(GetSyncWebhooksEnabledResponse {
managed: false,
refresh_enabled: false,
sync_enabled: false,
});
};
let sync = resource::get_check_permissions::<ResourceSync>(
&sync,
&user,
PermissionLevel::Read,
)
.await?;
if sync.config.git_provider != "github.com"
|| sync.config.repo.is_empty()
{
return Ok(GetSyncWebhooksEnabledResponse {
managed: false,
refresh_enabled: false,
sync_enabled: false,
});
}
let mut split = sync.config.repo.split('/');
let owner = split.next().context("Sync repo has no owner")?;
let Some(github) = github.get(owner) else {
return Ok(GetSyncWebhooksEnabledResponse {
managed: false,
refresh_enabled: false,
sync_enabled: false,
});
};
let repo_name =
split.next().context("Repo repo has no repo after the /")?;
let github_repos = github.repos();
let webhooks = github_repos
.list_all_webhooks(owner, repo_name)
.await
.context("failed to list all webhooks on repo")?
.body;
let CoreConfig {
host,
webhook_base_url,
..
} = core_config();
let host = webhook_base_url.as_ref().unwrap_or(host);
let refresh_url =
format!("{host}/listener/github/sync/{}/refresh", sync.id);
let sync_url =
format!("{host}/listener/github/sync/{}/sync", sync.id);
let mut refresh_enabled = false;
let mut sync_enabled = false;
for webhook in webhooks {
if webhook.active && webhook.config.url == refresh_url {
refresh_enabled = true
}
if webhook.active && webhook.config.url == sync_url {
sync_enabled = true
}
}
Ok(GetSyncWebhooksEnabledResponse {
managed: true,
refresh_enabled,
sync_enabled,
})
}
}

View File

@@ -1,39 +1,33 @@
use anyhow::Context;
use mongo_indexed::doc;
use monitor_client::{
api::read::{GetTag, ListTags},
entities::{tag::Tag, user::User},
use database::mongo_indexed::doc;
use database::mungos::{
find::find_collect, mongodb::options::FindOptions,
};
use komodo_client::{
api::read::{GetTag, ListTags},
entities::tag::Tag,
};
use mungos::{find::find_collect, mongodb::options::FindOptions};
use resolver_api::Resolve;
use crate::{
helpers::query::get_tag,
state::{db_client, State},
};
use crate::{helpers::query::get_tag, state::db_client};
impl Resolve<GetTag, User> for State {
async fn resolve(
&self,
GetTag { tag }: GetTag,
_: User,
) -> anyhow::Result<Tag> {
get_tag(&tag).await
use super::ReadArgs;
impl Resolve<ReadArgs> for GetTag {
async fn resolve(self, _: &ReadArgs) -> serror::Result<Tag> {
Ok(get_tag(&self.tag).await?)
}
}
impl Resolve<ListTags, User> for State {
async fn resolve(
&self,
ListTags { query }: ListTags,
_: User,
) -> anyhow::Result<Vec<Tag>> {
find_collect(
&db_client().await.tags,
query,
impl Resolve<ReadArgs> for ListTags {
async fn resolve(self, _: &ReadArgs) -> serror::Result<Vec<Tag>> {
let res = find_collect(
&db_client().tags,
self.query,
FindOptions::builder().sort(doc! { "name": 1 }).build(),
)
.await
.context("failed to get tags from db")
.context("failed to get tags from db")?;
Ok(res)
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,16 @@
use std::collections::HashMap;
use anyhow::{anyhow, Context};
use monitor_client::{
use anyhow::{Context, anyhow};
use database::mungos::{
by_id::find_one_by_id,
find::find_collect,
mongodb::{bson::doc, options::FindOptions},
};
use komodo_client::{
api::read::{GetUpdate, ListUpdates, ListUpdatesResponse},
entities::{
ResourceTarget,
action::Action,
alerter::Alerter,
build::Build,
builder::Builder,
@@ -12,49 +19,43 @@ use monitor_client::{
procedure::Procedure,
repo::Repo,
server::Server,
server_template::ServerTemplate,
stack::Stack,
sync::ResourceSync,
update::{ResourceTarget, Update, UpdateListItem},
update::{Update, UpdateListItem},
user::User,
},
};
use mungos::{
by_id::find_one_by_id,
find::find_collect,
mongodb::{bson::doc, options::FindOptions},
};
use resolver_api::Resolve;
use crate::{
config::core_config,
resource,
state::{db_client, State},
permission::{get_check_permissions, get_resource_ids_for_user},
state::db_client,
};
use super::ReadArgs;
const UPDATES_PER_PAGE: i64 = 100;
impl Resolve<ListUpdates, User> for State {
impl Resolve<ReadArgs> for ListUpdates {
async fn resolve(
&self,
ListUpdates { query, page }: ListUpdates,
user: User,
) -> anyhow::Result<ListUpdatesResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListUpdatesResponse> {
let query = if user.admin || core_config().transparent_mode {
query
self.query
} else {
let server_query =
resource::get_resource_ids_for_user::<Server>(&user)
.await?
.map(|ids| {
doc! {
"target.type": "Server", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Server" });
let server_query = get_resource_ids_for_user::<Server>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Server", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Server" });
let deployment_query =
resource::get_resource_ids_for_user::<Deployment>(&user)
get_resource_ids_for_user::<Deployment>(user)
.await?
.map(|ids| {
doc! {
@@ -63,38 +64,35 @@ impl Resolve<ListUpdates, User> for State {
})
.unwrap_or_else(|| doc! { "target.type": "Deployment" });
let stack_query =
resource::get_resource_ids_for_user::<Stack>(&user)
.await?
.map(|ids| {
doc! {
"target.type": "Stack", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Stack" });
let stack_query = get_resource_ids_for_user::<Stack>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Stack", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Stack" });
let build_query =
resource::get_resource_ids_for_user::<Build>(&user)
.await?
.map(|ids| {
doc! {
"target.type": "Build", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Build" });
let build_query = get_resource_ids_for_user::<Build>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Build", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Build" });
let repo_query =
resource::get_resource_ids_for_user::<Repo>(&user)
.await?
.map(|ids| {
doc! {
"target.type": "Repo", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Repo" });
let repo_query = get_resource_ids_for_user::<Repo>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Repo", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Repo" });
let procedure_query =
resource::get_resource_ids_for_user::<Procedure>(&user)
get_resource_ids_for_user::<Procedure>(user)
.await?
.map(|ids| {
doc! {
@@ -103,40 +101,36 @@ impl Resolve<ListUpdates, User> for State {
})
.unwrap_or_else(|| doc! { "target.type": "Procedure" });
let builder_query =
resource::get_resource_ids_for_user::<Builder>(&user)
.await?
.map(|ids| {
doc! {
"target.type": "Builder", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Builder" });
let action_query = get_resource_ids_for_user::<Action>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Action", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Action" });
let alerter_query =
resource::get_resource_ids_for_user::<Alerter>(&user)
.await?
.map(|ids| {
doc! {
"target.type": "Alerter", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Alerter" });
let builder_query = get_resource_ids_for_user::<Builder>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Builder", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Builder" });
let server_template_query = resource::get_resource_ids_for_user::<ServerTemplate>(
&user,
)
.await?
.map(|ids| {
doc! {
"target.type": "ServerTemplate", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "ServerTemplate" });
let alerter_query = get_resource_ids_for_user::<Alerter>(user)
.await?
.map(|ids| {
doc! {
"target.type": "Alerter", "target.id": { "$in": ids }
}
})
.unwrap_or_else(|| doc! { "target.type": "Alerter" });
let resource_sync_query = resource::get_resource_ids_for_user::<ResourceSync>(
&user,
)
let resource_sync_query = get_resource_ids_for_user::<
ResourceSync,
>(user)
.await?
.map(|ids| {
doc! {
@@ -145,7 +139,7 @@ impl Resolve<ListUpdates, User> for State {
})
.unwrap_or_else(|| doc! { "target.type": "ResourceSync" });
let mut query = query.unwrap_or_default();
let mut query = self.query.unwrap_or_default();
query.extend(doc! {
"$or": [
server_query,
@@ -154,29 +148,28 @@ impl Resolve<ListUpdates, User> for State {
build_query,
repo_query,
procedure_query,
action_query,
alerter_query,
builder_query,
server_template_query,
resource_sync_query,
]
});
query.into()
};
let usernames =
find_collect(&db_client().await.users, None, None)
.await
.context("failed to pull users from db")?
.into_iter()
.map(|u| (u.id, u.username))
.collect::<HashMap<_, _>>();
let usernames = find_collect(&db_client().users, None, None)
.await
.context("failed to pull users from db")?
.into_iter()
.map(|u| (u.id, u.username))
.collect::<HashMap<_, _>>();
let updates = find_collect(
&db_client().await.updates,
&db_client().updates,
query,
FindOptions::builder()
.sort(doc! { "start_ts": -1 })
.skip(page as u64 * UPDATES_PER_PAGE as u64)
.skip(self.page as u64 * UPDATES_PER_PAGE as u64)
.limit(UPDATES_PER_PAGE)
.build(),
)
@@ -208,7 +201,7 @@ impl Resolve<ListUpdates, User> for State {
.collect::<Vec<_>>();
let next_page = if updates.len() == UPDATES_PER_PAGE as usize {
Some(page + 1)
Some(self.page + 1)
} else {
None
};
@@ -217,13 +210,12 @@ impl Resolve<ListUpdates, User> for State {
}
}
impl Resolve<GetUpdate, User> for State {
impl Resolve<ReadArgs> for GetUpdate {
async fn resolve(
&self,
GetUpdate { id }: GetUpdate,
user: User,
) -> anyhow::Result<Update> {
let update = find_one_by_id(&db_client().await.updates, &id)
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<Update> {
let update = find_one_by_id(&db_client().updates, &self.id)
.await
.context("failed to query to db")?
.context("no update exists with given id")?;
@@ -232,87 +224,87 @@ impl Resolve<GetUpdate, User> for State {
}
match &update.target {
ResourceTarget::System(_) => {
return Err(anyhow!(
"user must be admin to view system updates"
))
return Err(
anyhow!("user must be admin to view system updates").into(),
);
}
ResourceTarget::Server(id) => {
resource::get_check_permissions::<Server>(
get_check_permissions::<Server>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Deployment(id) => {
resource::get_check_permissions::<Deployment>(
get_check_permissions::<Deployment>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Build(id) => {
resource::get_check_permissions::<Build>(
get_check_permissions::<Build>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Repo(id) => {
resource::get_check_permissions::<Repo>(
get_check_permissions::<Repo>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Builder(id) => {
resource::get_check_permissions::<Builder>(
get_check_permissions::<Builder>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Alerter(id) => {
resource::get_check_permissions::<Alerter>(
get_check_permissions::<Alerter>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Procedure(id) => {
resource::get_check_permissions::<Procedure>(
get_check_permissions::<Procedure>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::ServerTemplate(id) => {
resource::get_check_permissions::<ServerTemplate>(
ResourceTarget::Action(id) => {
get_check_permissions::<Action>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::ResourceSync(id) => {
resource::get_check_permissions::<ResourceSync>(
get_check_permissions::<ResourceSync>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}
ResourceTarget::Stack(id) => {
resource::get_check_permissions::<Stack>(
get_check_permissions::<Stack>(
id,
&user,
PermissionLevel::Read,
user,
PermissionLevel::Read.into(),
)
.await?;
}

View File

@@ -1,32 +1,37 @@
use anyhow::{anyhow, Context};
use monitor_client::{
use anyhow::{Context, anyhow};
use database::mungos::{
by_id::find_one_by_id,
find::find_collect,
mongodb::{bson::doc, options::FindOptions},
};
use komodo_client::{
api::read::{
FindUser, FindUserResponse, GetUsername, GetUsernameResponse,
ListApiKeys, ListApiKeysForServiceUser,
ListApiKeysForServiceUserResponse, ListApiKeysResponse,
ListUsers, ListUsersResponse,
},
entities::user::{User, UserConfig},
};
use mungos::{
by_id::find_one_by_id,
find::find_collect,
mongodb::{bson::doc, options::FindOptions},
entities::user::{UserConfig, admin_service_user},
};
use resolver_api::Resolve;
use crate::{
helpers::query::get_user,
state::{db_client, State},
};
use crate::{helpers::query::get_user, state::db_client};
impl Resolve<GetUsername, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetUsername {
async fn resolve(
&self,
GetUsername { user_id }: GetUsername,
_: User,
) -> anyhow::Result<GetUsernameResponse> {
let user = find_one_by_id(&db_client().await.users, &user_id)
self,
_: &ReadArgs,
) -> serror::Result<GetUsernameResponse> {
if let Some(user) = admin_service_user(&self.user_id) {
return Ok(GetUsernameResponse {
username: user.username,
avatar: None,
});
}
let user = find_one_by_id(&db_client().users, &self.user_id)
.await
.context("failed at mongo query for user")?
.context("no user found with id")?;
@@ -44,30 +49,30 @@ impl Resolve<GetUsername, User> for State {
}
}
impl Resolve<FindUser, User> for State {
impl Resolve<ReadArgs> for FindUser {
async fn resolve(
&self,
FindUser { user }: FindUser,
admin: User,
) -> anyhow::Result<FindUserResponse> {
self,
ReadArgs { user: admin }: &ReadArgs,
) -> serror::Result<FindUserResponse> {
if !admin.admin {
return Err(anyhow!("This method is admin only."));
return Err(anyhow!("This method is admin only.").into());
}
get_user(&user).await
Ok(get_user(&self.user).await?)
}
}
impl Resolve<ListUsers, User> for State {
impl Resolve<ReadArgs> for ListUsers {
async fn resolve(
&self,
ListUsers {}: ListUsers,
user: User,
) -> anyhow::Result<ListUsersResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListUsersResponse> {
if !user.admin {
return Err(anyhow!("this route is only accessable by admins"));
return Err(
anyhow!("this route is only accessable by admins").into(),
);
}
let mut users = find_collect(
&db_client().await.users,
&db_client().users,
None,
FindOptions::builder().sort(doc! { "username": 1 }).build(),
)
@@ -78,14 +83,13 @@ impl Resolve<ListUsers, User> for State {
}
}
impl Resolve<ListApiKeys, User> for State {
impl Resolve<ReadArgs> for ListApiKeys {
async fn resolve(
&self,
ListApiKeys {}: ListApiKeys,
user: User,
) -> anyhow::Result<ListApiKeysResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListApiKeysResponse> {
let api_keys = find_collect(
&db_client().await.api_keys,
&db_client().api_keys,
doc! { "user_id": &user.id },
FindOptions::builder().sort(doc! { "name": 1 }).build(),
)
@@ -101,23 +105,22 @@ impl Resolve<ListApiKeys, User> for State {
}
}
impl Resolve<ListApiKeysForServiceUser, User> for State {
impl Resolve<ReadArgs> for ListApiKeysForServiceUser {
async fn resolve(
&self,
ListApiKeysForServiceUser { user }: ListApiKeysForServiceUser,
admin: User,
) -> anyhow::Result<ListApiKeysForServiceUserResponse> {
self,
ReadArgs { user: admin }: &ReadArgs,
) -> serror::Result<ListApiKeysForServiceUserResponse> {
if !admin.admin {
return Err(anyhow!("This method is admin only."));
return Err(anyhow!("This method is admin only.").into());
}
let user = get_user(&user).await?;
let user = get_user(&self.user).await?;
let UserConfig::Service { .. } = user.config else {
return Err(anyhow!("Given user is not service user"));
return Err(anyhow!("Given user is not service user").into());
};
let api_keys = find_collect(
&db_client().await.api_keys,
&db_client().api_keys,
doc! { "user_id": &user.id },
None,
)

View File

@@ -1,65 +1,60 @@
use std::str::FromStr;
use anyhow::Context;
use monitor_client::{
api::read::{
GetUserGroup, GetUserGroupResponse, ListUserGroups,
ListUserGroupsResponse,
},
entities::user::User,
};
use mungos::{
use database::mungos::{
find::find_collect,
mongodb::{
bson::{doc, oid::ObjectId, Document},
bson::{Document, doc, oid::ObjectId},
options::FindOptions,
},
};
use komodo_client::api::read::*;
use resolver_api::Resolve;
use crate::state::{db_client, State};
use crate::state::db_client;
impl Resolve<GetUserGroup, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetUserGroup {
async fn resolve(
&self,
GetUserGroup { user_group }: GetUserGroup,
user: User,
) -> anyhow::Result<GetUserGroupResponse> {
let mut filter = match ObjectId::from_str(&user_group) {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetUserGroupResponse> {
let mut filter = match ObjectId::from_str(&self.user_group) {
Ok(id) => doc! { "_id": id },
Err(_) => doc! { "name": &user_group },
Err(_) => doc! { "name": &self.user_group },
};
// Don't allow non admin users to get UserGroups they aren't a part of.
if !user.admin {
// Filter for only UserGroups which contain the users id
filter.insert("users", &user.id);
}
db_client()
.await
let res = db_client()
.user_groups
.find_one(filter)
.await
.context("failed to query db for user groups")?
.context("no UserGroup found with given name or id")
.context("no UserGroup found with given name or id")?;
Ok(res)
}
}
impl Resolve<ListUserGroups, User> for State {
impl Resolve<ReadArgs> for ListUserGroups {
async fn resolve(
&self,
ListUserGroups {}: ListUserGroups,
user: User,
) -> anyhow::Result<ListUserGroupsResponse> {
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListUserGroupsResponse> {
let mut filter = Document::new();
if !user.admin {
filter.insert("users", &user.id);
}
find_collect(
&db_client().await.user_groups,
let res = find_collect(
&db_client().user_groups,
filter,
FindOptions::builder().sort(doc! { "name": 1 }).build(),
)
.await
.context("failed to query db for UserGroups")
.context("failed to query db for UserGroups")?;
Ok(res)
}
}

View File

@@ -1,42 +1,53 @@
use anyhow::Context;
use mongo_indexed::doc;
use monitor_client::{
api::read::{
GetVariable, GetVariableResponse, ListVariables,
ListVariablesResponse,
},
entities::user::User,
use database::mongo_indexed::doc;
use database::mungos::{
find::find_collect, mongodb::options::FindOptions,
};
use mungos::{find::find_collect, mongodb::options::FindOptions};
use komodo_client::api::read::*;
use resolver_api::Resolve;
use crate::{
helpers::query::get_variable,
state::{db_client, State},
};
use crate::{helpers::query::get_variable, state::db_client};
impl Resolve<GetVariable, User> for State {
use super::ReadArgs;
impl Resolve<ReadArgs> for GetVariable {
async fn resolve(
&self,
GetVariable { name }: GetVariable,
_: User,
) -> anyhow::Result<GetVariableResponse> {
get_variable(&name).await
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<GetVariableResponse> {
let mut variable = get_variable(&self.name).await?;
if !variable.is_secret || user.admin {
return Ok(variable);
}
variable.value = "#".repeat(variable.value.len());
Ok(variable)
}
}
impl Resolve<ListVariables, User> for State {
impl Resolve<ReadArgs> for ListVariables {
async fn resolve(
&self,
ListVariables {}: ListVariables,
_: User,
) -> anyhow::Result<ListVariablesResponse> {
find_collect(
&db_client().await.variables,
self,
ReadArgs { user }: &ReadArgs,
) -> serror::Result<ListVariablesResponse> {
let variables = find_collect(
&db_client().variables,
None,
FindOptions::builder().sort(doc! { "name": 1 }).build(),
)
.await
.context("failed to query db for variables")
.context("failed to query db for variables")?;
if user.admin {
return Ok(variables);
}
let variables = variables
.into_iter()
.map(|mut variable| {
if variable.is_secret {
variable.value = "#".repeat(variable.value.len());
}
variable
})
.collect();
Ok(variables)
}
}

View File

@@ -0,0 +1,248 @@
use anyhow::Context;
use axum::{Extension, Router, middleware, routing::post};
use komodo_client::{
api::{terminal::*, write::TerminalRecreateMode},
entities::{
deployment::Deployment, permission::PermissionLevel,
server::Server, stack::Stack, user::User,
},
};
use periphery_client::api::terminal::CreateTerminal;
use serror::Json;
use crate::{
auth::auth_request, helpers::periphery_client,
permission::get_check_permissions, resource::get,
state::stack_status_cache,
};
pub fn router() -> Router {
Router::new()
.route("/execute", post(execute_terminal))
.route("/execute/container", post(execute_container_exec))
.route("/execute/deployment", post(execute_deployment_exec))
.route("/execute/stack", post(execute_stack_exec))
.layer(middleware::from_fn(auth_request))
}
// =================
// ExecuteTerminal
// =================
#[instrument(
name = "ExecuteTerminal",
skip_all,
fields(
operator = user.id,
server,
terminal,
)
)]
async fn execute_terminal(
Extension(user): Extension<User>,
Json(ExecuteTerminalBody {
server,
terminal,
command,
init,
}): Json<ExecuteTerminalBody>,
) -> serror::Result<axum::body::Body> {
info!("/terminal/execute request | user: {}", user.username);
let server = get_check_permissions::<Server>(
&server,
&user,
PermissionLevel::Read.terminal(),
)
.await?;
let periphery = periphery_client(&server).await?;
// Maybe init terminal.
if let Some(InitTerminal { command, recreate }) = init {
periphery
.request(CreateTerminal {
name: terminal.clone(),
command,
recreate,
})
.await?;
}
let stream = periphery
.execute_terminal(terminal, command)
.await
.context("Failed to execute command on periphery")?;
Ok(axum::body::Body::from_stream(stream))
}
// ======================
// ExecuteContainerExec
// ======================
#[instrument(
name = "ExecuteContainerExec",
skip_all,
fields(
operator = user.id,
server,
container,
shell,
recreate = format!("{recreate:?}"),
)
)]
async fn execute_container_exec(
Extension(user): Extension<User>,
Json(ExecuteContainerExecBody {
server,
container,
shell,
command,
recreate,
}): Json<ExecuteContainerExecBody>,
) -> serror::Result<axum::body::Body> {
info!("ExecuteContainerExec request | user: {}", user.username);
let server = get_check_permissions::<Server>(
&server,
&user,
PermissionLevel::Read.terminal(),
)
.await?;
let periphery = periphery_client(&server).await?;
let stream = periphery
.execute_container_exec(
container,
shell,
command,
recreate.unwrap_or(TerminalRecreateMode::DifferentCommand),
)
.await
.context(
"Failed to execute container exec command on periphery",
)?;
Ok(axum::body::Body::from_stream(stream))
}
// =======================
// ExecuteDeploymentExec
// =======================
#[instrument(
name = "ExecuteDeploymentExec",
skip_all,
fields(
operator = user.id,
deployment,
shell,
recreate = format!("{recreate:?}"),
)
)]
async fn execute_deployment_exec(
Extension(user): Extension<User>,
Json(ExecuteDeploymentExecBody {
deployment,
shell,
command,
recreate,
}): Json<ExecuteDeploymentExecBody>,
) -> serror::Result<axum::body::Body> {
info!("ExecuteDeploymentExec request | user: {}", user.username);
let deployment = get_check_permissions::<Deployment>(
&deployment,
&user,
PermissionLevel::Read.terminal(),
)
.await?;
let server = get::<Server>(&deployment.config.server_id).await?;
let periphery = periphery_client(&server).await?;
let stream = periphery
.execute_container_exec(
deployment.name,
shell,
command,
recreate.unwrap_or(TerminalRecreateMode::DifferentCommand),
)
.await
.context(
"Failed to execute container exec command on periphery",
)?;
Ok(axum::body::Body::from_stream(stream))
}
// ==================
// ExecuteStackExec
// ==================
#[instrument(
name = "ExecuteStackExec",
skip_all,
fields(
operator = user.id,
stack,
service,
shell,
recreate = format!("{recreate:?}"),
)
)]
async fn execute_stack_exec(
Extension(user): Extension<User>,
Json(ExecuteStackExecBody {
stack,
service,
shell,
command,
recreate,
}): Json<ExecuteStackExecBody>,
) -> serror::Result<axum::body::Body> {
info!("ExecuteStackExec request | user: {}", user.username);
let stack = get_check_permissions::<Stack>(
&stack,
&user,
PermissionLevel::Read.terminal(),
)
.await?;
let server = get::<Server>(&stack.config.server_id).await?;
let container = stack_status_cache()
.get(&stack.id)
.await
.context("could not get stack status")?
.curr
.services
.iter()
.find(|s| s.service == service)
.context("could not find service")?
.container
.as_ref()
.context("could not find service container")?
.name
.clone();
let periphery = periphery_client(&server).await?;
let stream = periphery
.execute_container_exec(
container,
shell,
command,
recreate.unwrap_or(TerminalRecreateMode::DifferentCommand),
)
.await
.context(
"Failed to execute container exec command on periphery",
)?;
Ok(axum::body::Body::from_stream(stream))
}

View File

@@ -1,34 +1,43 @@
use std::{collections::VecDeque, time::Instant};
use anyhow::{anyhow, Context};
use axum::{middleware, routing::post, Extension, Json, Router};
use axum_extra::{headers::ContentType, TypedHeader};
use mongo_indexed::doc;
use monitor_client::{
api::user::{
CreateApiKey, CreateApiKeyResponse, DeleteApiKey,
DeleteApiKeyResponse, PushRecentlyViewed,
PushRecentlyViewedResponse, SetLastSeenUpdate,
SetLastSeenUpdateResponse,
},
entities::{api_key::ApiKey, monitor_timestamp, user::User},
use anyhow::{Context, anyhow};
use axum::{
Extension, Json, Router, extract::Path, middleware, routing::post,
};
use mungos::{by_id::update_one_by_id, mongodb::bson::to_bson};
use resolver_api::{derive::Resolver, Resolve, Resolver};
use database::mongo_indexed::doc;
use database::mungos::{
by_id::update_one_by_id, mongodb::bson::to_bson,
};
use derive_variants::EnumVariants;
use komodo_client::entities::random_string;
use komodo_client::{
api::user::*,
entities::{api_key::ApiKey, komodo_timestamp, user::User},
};
use resolver_api::Resolve;
use response::Response;
use serde::{Deserialize, Serialize};
use serde_json::json;
use typeshare::typeshare;
use uuid::Uuid;
use crate::{
auth::auth_request,
helpers::{query::get_user, random_string},
state::{db_client, State},
auth::auth_request, helpers::query::get_user, state::db_client,
};
use super::Variant;
pub struct UserArgs {
pub user: User,
}
#[typeshare]
#[derive(Serialize, Deserialize, Debug, Clone, Resolver)]
#[resolver_target(State)]
#[resolver_args(User)]
#[derive(
Serialize, Deserialize, Debug, Clone, Resolve, EnumVariants,
)]
#[args(UserArgs)]
#[response(Response)]
#[error(serror::Error)]
#[serde(tag = "type", content = "params")]
enum UserRequest {
PushRecentlyViewed(PushRecentlyViewed),
@@ -40,54 +49,51 @@ enum UserRequest {
pub fn router() -> Router {
Router::new()
.route("/", post(handler))
.route("/{variant}", post(variant_handler))
.layer(middleware::from_fn(auth_request))
}
#[instrument(name = "UserHandler", level = "debug", skip(user))]
async fn variant_handler(
user: Extension<User>,
Path(Variant { variant }): Path<Variant>,
Json(params): Json<serde_json::Value>,
) -> serror::Result<axum::response::Response> {
let req: UserRequest = serde_json::from_value(json!({
"type": variant,
"params": params,
}))?;
handler(user, Json(req)).await
}
async fn handler(
Extension(user): Extension<User>,
Json(request): Json<UserRequest>,
) -> serror::Result<(TypedHeader<ContentType>, String)> {
) -> serror::Result<axum::response::Response> {
let timer = Instant::now();
let req_id = Uuid::new_v4();
debug!(
"/user request {req_id} | user: {} ({})",
user.username, user.id
);
let res =
State
.resolve_request(request, user)
.await
.map_err(|e| match e {
resolver_api::Error::Serialization(e) => {
anyhow!("{e:?}").context("response serialization error")
}
resolver_api::Error::Inner(e) => e,
});
let res = request.resolve(&UserArgs { user }).await;
if let Err(e) = &res {
warn!("/user request {req_id} error: {e:#}");
warn!("/user request {req_id} error: {:#}", e.error);
}
let elapsed = timer.elapsed();
debug!("/user request {req_id} | resolve time: {elapsed:?}");
Ok((TypedHeader(ContentType::json()), res?))
res.map(|res| res.0)
}
const RECENTLY_VIEWED_MAX: usize = 10;
impl Resolve<PushRecentlyViewed, User> for State {
#[instrument(
name = "PushRecentlyViewed",
level = "debug",
skip(self, user)
)]
impl Resolve<UserArgs> for PushRecentlyViewed {
async fn resolve(
&self,
PushRecentlyViewed { resource }: PushRecentlyViewed,
user: User,
) -> anyhow::Result<PushRecentlyViewedResponse> {
self,
UserArgs { user }: &UserArgs,
) -> serror::Result<PushRecentlyViewedResponse> {
let user = get_user(&user.id).await?;
let (resource_type, id) = resource.extract_variant_id();
let (resource_type, id) = self.resource.extract_variant_id();
let update = match user.recents.get(&resource_type) {
Some(recents) => {
let mut recents = recents
@@ -103,9 +109,9 @@ impl Resolve<PushRecentlyViewed, User> for State {
}
};
update_one_by_id(
&db_client().await.users,
&db_client().users,
&user.id,
mungos::update::Update::Set(update),
database::mungos::update::Update::Set(update),
None,
)
.await
@@ -117,22 +123,16 @@ impl Resolve<PushRecentlyViewed, User> for State {
}
}
impl Resolve<SetLastSeenUpdate, User> for State {
#[instrument(
name = "SetLastSeenUpdate",
level = "debug",
skip(self, user)
)]
impl Resolve<UserArgs> for SetLastSeenUpdate {
async fn resolve(
&self,
SetLastSeenUpdate {}: SetLastSeenUpdate,
user: User,
) -> anyhow::Result<SetLastSeenUpdateResponse> {
self,
UserArgs { user }: &UserArgs,
) -> serror::Result<SetLastSeenUpdateResponse> {
update_one_by_id(
&db_client().await.users,
&db_client().users,
&user.id,
mungos::update::Update::Set(doc! {
"last_update_view": monitor_timestamp()
database::mungos::update::Update::Set(doc! {
"last_update_view": komodo_timestamp()
}),
None,
)
@@ -145,17 +145,16 @@ impl Resolve<SetLastSeenUpdate, User> for State {
const SECRET_LENGTH: usize = 40;
const BCRYPT_COST: u32 = 10;
impl Resolve<CreateApiKey, User> for State {
impl Resolve<UserArgs> for CreateApiKey {
#[instrument(
name = "CreateApiKey",
level = "debug",
skip(self, user)
"CreateApiKey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
&self,
CreateApiKey { name, expires }: CreateApiKey,
user: User,
) -> anyhow::Result<CreateApiKeyResponse> {
self,
UserArgs { user }: &UserArgs,
) -> serror::Result<CreateApiKeyResponse> {
let user = get_user(&user.id).await?;
let key = format!("K-{}", random_string(SECRET_LENGTH));
@@ -164,15 +163,14 @@ impl Resolve<CreateApiKey, User> for State {
.context("failed at hashing secret string")?;
let api_key = ApiKey {
name,
name: self.name,
key: key.clone(),
secret: secret_hash,
user_id: user.id.clone(),
created_at: monitor_timestamp(),
expires,
created_at: komodo_timestamp(),
expires: self.expires,
};
db_client()
.await
.api_keys
.insert_one(api_key)
.await
@@ -181,26 +179,25 @@ impl Resolve<CreateApiKey, User> for State {
}
}
impl Resolve<DeleteApiKey, User> for State {
impl Resolve<UserArgs> for DeleteApiKey {
#[instrument(
name = "DeleteApiKey",
level = "debug",
skip(self, user)
"DeleteApiKey",
skip_all,
fields(operator = user.id)
)]
async fn resolve(
&self,
DeleteApiKey { key }: DeleteApiKey,
user: User,
) -> anyhow::Result<DeleteApiKeyResponse> {
let client = db_client().await;
self,
UserArgs { user }: &UserArgs,
) -> serror::Result<DeleteApiKeyResponse> {
let client = db_client();
let key = client
.api_keys
.find_one(doc! { "key": &key })
.find_one(doc! { "key": &self.key })
.await
.context("failed at db query")?
.context("no api key with key found")?;
if user.id != key.user_id {
return Err(anyhow!("api key does not belong to user"));
return Err(anyhow!("api key does not belong to user").into());
}
client
.api_keys

View File

@@ -0,0 +1,108 @@
use komodo_client::{
api::write::*,
entities::{
action::Action, permission::PermissionLevel, update::Update,
},
};
use resolver_api::Resolve;
use crate::{permission::get_check_permissions, resource};
use super::WriteArgs;
impl Resolve<WriteArgs> for CreateAction {
#[instrument(
"CreateAction",
skip_all,
fields(
operator = user.id,
action = self.name,
config = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
resource::create::<Action>(&self.name, self.config, None, user)
.await
}
}
impl Resolve<WriteArgs> for CopyAction {
#[instrument(
"CopyAction",
skip_all,
fields(
operator = user.id,
action = self.name,
copy_action = self.id,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
let Action { config, .. } = get_check_permissions::<Action>(
&self.id,
user,
PermissionLevel::Write.into(),
)
.await?;
resource::create::<Action>(&self.name, config.into(), None, user)
.await
}
}
impl Resolve<WriteArgs> for UpdateAction {
#[instrument(
"UpdateAction",
skip_all,
fields(
operator = user.id,
action = self.id,
update = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
Ok(resource::update::<Action>(&self.id, self.config, user).await?)
}
}
impl Resolve<WriteArgs> for RenameAction {
#[instrument(
"RenameAction",
skip_all,
fields(
operator = user.id,
action = self.id,
new_name = self.name,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Update> {
Ok(resource::rename::<Action>(&self.id, &self.name, user).await?)
}
}
impl Resolve<WriteArgs> for DeleteAction {
#[instrument(
"DeleteAction",
skip_all,
fields(
operator = user.id,
action = self.id
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Action> {
Ok(resource::delete::<Action>(&self.id, user).await?)
}
}

View File

@@ -0,0 +1,41 @@
use std::str::FromStr;
use anyhow::{Context, anyhow};
use database::mungos::mongodb::bson::{doc, oid::ObjectId};
use komodo_client::{api::write::CloseAlert, entities::NoData};
use reqwest::StatusCode;
use resolver_api::Resolve;
use serror::AddStatusCodeError;
use crate::{api::write::WriteArgs, state::db_client};
impl Resolve<WriteArgs> for CloseAlert {
#[instrument(
"CloseAlert",
skip_all,
fields(
operator = admin.id,
alert_id = self.id,
)
)]
async fn resolve(
self,
WriteArgs { user: admin }: &WriteArgs,
) -> Result<Self::Response, Self::Error> {
if !admin.admin {
return Err(
anyhow!("This call is admin only")
.status_code(StatusCode::FORBIDDEN),
);
}
db_client()
.alerts
.update_one(
doc! { "_id": ObjectId::from_str(&self.id)? },
doc! { "$set": { "resolved": true } },
)
.await
.context("Failed to close Alert on database")?;
Ok(NoData {})
}
}

View File

@@ -1,61 +1,111 @@
use monitor_client::{
api::write::{
CopyAlerter, CreateAlerter, DeleteAlerter, UpdateAlerter,
},
use komodo_client::{
api::write::*,
entities::{
alerter::Alerter, permission::PermissionLevel, user::User,
alerter::Alerter, permission::PermissionLevel, update::Update,
},
};
use resolver_api::Resolve;
use crate::{resource, state::State};
use crate::{permission::get_check_permissions, resource};
impl Resolve<CreateAlerter, User> for State {
#[instrument(name = "CreateAlerter", skip(self, user))]
use super::WriteArgs;
impl Resolve<WriteArgs> for CreateAlerter {
#[instrument(
"CreateAlerter",
skip_all,
fields(
operator = user.id,
alerter = self.name,
config = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
&self,
CreateAlerter { name, config }: CreateAlerter,
user: User,
) -> anyhow::Result<Alerter> {
resource::create::<Alerter>(&name, config, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
resource::create::<Alerter>(&self.name, self.config, None, user)
.await
}
}
impl Resolve<CopyAlerter, User> for State {
#[instrument(name = "CopyAlerter", skip(self, user))]
impl Resolve<WriteArgs> for CopyAlerter {
#[instrument(
"CopyAlerter",
skip_all,
fields(
operator = user.id,
alerter = self.name,
copy_alerter = self.id,
)
)]
async fn resolve(
&self,
CopyAlerter { name, id }: CopyAlerter,
user: User,
) -> anyhow::Result<Alerter> {
let Alerter { config, .. } = resource::get_check_permissions::<
Alerter,
>(
&id, &user, PermissionLevel::Write
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
let Alerter { config, .. } = get_check_permissions::<Alerter>(
&self.id,
user,
PermissionLevel::Write.into(),
)
.await?;
resource::create::<Alerter>(&name, config.into(), &user).await
resource::create::<Alerter>(&self.name, config.into(), None, user)
.await
}
}
impl Resolve<DeleteAlerter, User> for State {
#[instrument(name = "DeleteAlerter", skip(self, user))]
impl Resolve<WriteArgs> for DeleteAlerter {
#[instrument(
"DeleteAlerter",
skip_all,
fields(
operator = user.id,
alerter = self.id,
)
)]
async fn resolve(
&self,
DeleteAlerter { id }: DeleteAlerter,
user: User,
) -> anyhow::Result<Alerter> {
resource::delete::<Alerter>(&id, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
Ok(resource::delete::<Alerter>(&self.id, user).await?)
}
}
impl Resolve<UpdateAlerter, User> for State {
#[instrument(name = "UpdateAlerter", skip(self, user))]
impl Resolve<WriteArgs> for UpdateAlerter {
#[instrument(
"UpdateAlerter",
skip_all,
fields(
operator = user.id,
alerter = self.id,
update = serde_json::to_string(&self.config).unwrap()
)
)]
async fn resolve(
&self,
UpdateAlerter { id, config }: UpdateAlerter,
user: User,
) -> anyhow::Result<Alerter> {
resource::update::<Alerter>(&id, config, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Alerter> {
Ok(
resource::update::<Alerter>(&self.id, self.config, user)
.await?,
)
}
}
impl Resolve<WriteArgs> for RenameAlerter {
#[instrument(
"RenameAlerter",
skip_all,
fields(
operator = user.id,
alerter = self.id,
new_name = self.name,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Update> {
Ok(resource::rename::<Alerter>(&self.id, &self.name, user).await?)
}
}

View File

@@ -1,153 +1,455 @@
use anyhow::{anyhow, Context};
use mongo_indexed::doc;
use monitor_client::{
use std::{path::PathBuf, time::Duration};
use anyhow::{Context, anyhow};
use database::mungos::mongodb::bson::to_document;
use database::{
mongo_indexed::doc, mungos::mongodb::bson::oid::ObjectId,
};
use formatting::format_serror;
use komodo_client::{
api::write::*,
entities::{
build::{Build, BuildInfo, PartialBuildConfig},
config::core::CoreConfig,
FileContents, NoData, Operation, RepoExecutionArgs,
all_logs_success,
build::{Build, BuildInfo},
builder::{Builder, BuilderConfig},
permission::PermissionLevel,
user::User,
CloneArgs, NoData,
repo::Repo,
server::ServerState,
update::Update,
},
};
use mungos::mongodb::bson::to_document;
use octorust::types::{
ReposCreateWebhookRequest, ReposCreateWebhookRequestConfig,
use periphery_client::api::build::{
GetDockerfileContentsOnHost, WriteDockerfileContentsToHost,
};
use resolver_api::Resolve;
use tokio::fs;
use crate::{
config::core_config,
helpers::{git_token, random_string},
connection::PeripheryConnectionArgs,
helpers::{
git_token, periphery_client,
query::get_server_with_state,
update::{add_update, make_update},
},
periphery::PeripheryClient,
permission::get_check_permissions,
resource,
state::{db_client, github_client, State},
state::db_client,
};
impl Resolve<CreateBuild, User> for State {
#[instrument(name = "CreateBuild", skip(self, user))]
async fn resolve(
&self,
CreateBuild { name, config }: CreateBuild,
user: User,
) -> anyhow::Result<Build> {
resource::create::<Build>(&name, config, &user).await
}
}
use super::WriteArgs;
impl Resolve<CopyBuild, User> for State {
#[instrument(name = "CopyBuild", skip(self, user))]
async fn resolve(
&self,
CopyBuild { name, id }: CopyBuild,
user: User,
) -> anyhow::Result<Build> {
let Build { mut config, .. } =
resource::get_check_permissions::<Build>(
&id,
&user,
PermissionLevel::Write,
)
.await?;
// reset version to 0.0.0
config.version = Default::default();
resource::create::<Build>(&name, config.into(), &user).await
}
}
impl Resolve<DeleteBuild, User> for State {
#[instrument(name = "DeleteBuild", skip(self, user))]
async fn resolve(
&self,
DeleteBuild { id }: DeleteBuild,
user: User,
) -> anyhow::Result<Build> {
resource::delete::<Build>(&id, &user).await
}
}
impl Resolve<UpdateBuild, User> for State {
#[instrument(name = "UpdateBuild", skip(self, user))]
async fn resolve(
&self,
UpdateBuild { id, config }: UpdateBuild,
user: User,
) -> anyhow::Result<Build> {
resource::update::<Build>(&id, config, &user).await
}
}
impl Resolve<RefreshBuildCache, User> for State {
impl Resolve<WriteArgs> for CreateBuild {
#[instrument(
name = "RefreshBuildCache",
level = "debug",
skip(self, user)
"CreateBuild",
skip_all,
fields(
operator = user.id,
build = self.name,
config = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
&self,
RefreshBuildCache { build }: RefreshBuildCache,
user: User,
) -> anyhow::Result<NoData> {
// Even though this is a write request, this doesn't change any config. Anyone that can execute the
// build should be able to do this.
let build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Execute,
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
resource::create::<Build>(&self.name, self.config, None, user)
.await
}
}
impl Resolve<WriteArgs> for CopyBuild {
#[instrument(
"CopyBuild",
skip_all,
fields(
operator = user.id,
build = self.name,
copy_build = self.id,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
let Build { mut config, .. } = get_check_permissions::<Build>(
&self.id,
user,
PermissionLevel::Read.into(),
)
.await?;
// reset version to 0.0.0
config.version = Default::default();
resource::create::<Build>(&self.name, config.into(), None, user)
.await
}
}
impl Resolve<WriteArgs> for DeleteBuild {
#[instrument(
"DeleteBuild",
skip_all,
fields(
operator = user.id,
build = self.id,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
Ok(resource::delete::<Build>(&self.id, user).await?)
}
}
impl Resolve<WriteArgs> for UpdateBuild {
#[instrument(
"UpdateBuild",
skip_all,
fields(
operator = user.id,
build = self.id,
update = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Build> {
Ok(resource::update::<Build>(&self.id, self.config, user).await?)
}
}
impl Resolve<WriteArgs> for RenameBuild {
#[instrument(
"RenameBuild",
skip_all,
fields(
operator = user.id,
build = self.id,
new_name = self.name,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Update> {
Ok(resource::rename::<Build>(&self.id, &self.name, user).await?)
}
}
impl Resolve<WriteArgs> for WriteBuildFileContents {
#[instrument(
"WriteBuildFileContents",
skip_all,
fields(
operator = args.user.id,
build = self.build,
)
)]
async fn resolve(self, args: &WriteArgs) -> serror::Result<Update> {
let build = get_check_permissions::<Build>(
&self.build,
&args.user,
PermissionLevel::Write.into(),
)
.await?;
let config = core_config();
let repo_dir = config.repo_directory.join(random_string(10));
let mut clone_args: CloneArgs = (&build).into();
// Don't want to run these on core.
clone_args.on_clone = None;
clone_args.on_pull = None;
clone_args.destination = Some(repo_dir.display().to_string());
let access_token = match (&clone_args.account, &clone_args.provider)
if !build.config.files_on_host
&& build.config.repo.is_empty()
&& build.config.linked_repo.is_empty()
{
(None, _) => None,
(Some(_), None) => {
return Err(anyhow!(
"Account is configured, but provider is empty"
))
}
(Some(username), Some(provider)) => {
git_token(provider, username, |https| {
clone_args.https = https
return Err(anyhow!(
"Build is not configured to use Files on Host or Git Repo, can't write dockerfile contents"
).into());
}
let mut update =
make_update(&build, Operation::WriteDockerfile, &args.user);
update.push_simple_log("Dockerfile to write", &self.contents);
if build.config.files_on_host {
match get_on_host_periphery(&build)
.await?
.request(WriteDockerfileContentsToHost {
name: build.name,
build_path: build.config.build_path,
dockerfile_path: build.config.dockerfile_path,
contents: self.contents,
})
.await
.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {provider} | {username}"),
)?
.context("Failed to write dockerfile contents to host")
{
Ok(log) => {
update.logs.push(log);
}
Err(e) => {
update.push_error_log(
"Write Dockerfile Contents",
format_serror(&e.into()),
);
}
};
if !all_logs_success(&update.logs) {
update.finalize();
update.id = add_update(update.clone()).await?;
return Ok(update);
}
if let Err(e) =
(RefreshBuildCache { build: build.id }).resolve(args).await
{
update.push_error_log(
"Refresh build cache",
format_serror(&e.error.into()),
);
}
update.finalize();
update.id = add_update(update.clone()).await?;
Ok(update)
} else {
write_dockerfile_contents_git(self, args, build, update).await
}
}
}
#[instrument("WriteDockerfileContentsGit", skip_all)]
async fn write_dockerfile_contents_git(
req: WriteBuildFileContents,
args: &WriteArgs,
build: Build,
mut update: Update,
) -> serror::Result<Update> {
let WriteBuildFileContents { build: _, contents } = req;
let mut repo_args: RepoExecutionArgs = if !build
.config
.files_on_host
&& !build.config.linked_repo.is_empty()
{
(&crate::resource::get::<Repo>(&build.config.linked_repo).await?)
.into()
} else {
(&build).into()
};
let root = repo_args.unique_path(&core_config().repo_directory)?;
repo_args.destination = Some(root.display().to_string());
let build_path = build
.config
.build_path
.parse::<PathBuf>()
.context("Invalid build path")?;
let dockerfile_path = build
.config
.dockerfile_path
.parse::<PathBuf>()
.context("Invalid dockerfile path")?;
let full_path = root.join(&build_path).join(&dockerfile_path);
if let Some(parent) = full_path.parent() {
fs::create_dir_all(parent).await.with_context(|| {
format!(
"Failed to initialize dockerfile parent directory {parent:?}"
)
})?;
}
let access_token = if let Some(account) = &repo_args.account {
git_token(&repo_args.provider, account, |https| repo_args.https = https)
.await
.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {} | {account}", repo_args.provider),
)?
} else {
None
};
// Ensure the folder is initialized as git repo.
// This allows a new file to be committed on a branch that may not exist.
if !root.join(".git").exists() {
git::init_folder_as_repo(
&root,
&repo_args,
access_token.as_deref(),
&mut update.logs,
)
.await;
if !all_logs_success(&update.logs) {
update.finalize();
update.id = add_update(update.clone()).await?;
return Ok(update);
}
}
// Save this for later -- repo_args moved next.
let branch = repo_args.branch.clone();
// Pull latest changes to repo to ensure linear commit history
match git::pull_or_clone(
repo_args,
&core_config().repo_directory,
access_token,
)
.await
.context("Failed to pull latest changes before commit")
{
Ok((res, _)) => update.logs.extend(res.logs),
Err(e) => {
update.push_error_log("Pull Repo", format_serror(&e.into()));
update.finalize();
return Ok(update);
}
};
if !all_logs_success(&update.logs) {
update.finalize();
update.id = add_update(update.clone()).await?;
return Ok(update);
}
if let Err(e) = secret_file::write_async(&full_path, &contents)
.await
.with_context(|| {
format!("Failed to write dockerfile contents to {full_path:?}")
})
{
update
.push_error_log("Write Dockerfile", format_serror(&e.into()));
} else {
update.push_simple_log(
"Write Dockerfile",
format!("File written to {full_path:?}"),
);
};
if !all_logs_success(&update.logs) {
update.finalize();
update.id = add_update(update.clone()).await?;
return Ok(update);
}
let commit_res = git::commit_file(
&format!("{}: Commit Dockerfile", args.user.username),
&root,
&build_path.join(&dockerfile_path),
&branch,
)
.await;
update.logs.extend(commit_res.logs);
if let Err(e) = (RefreshBuildCache { build: build.name })
.resolve(args)
.await
{
update.push_error_log(
"Refresh build cache",
format_serror(&e.error.into()),
);
}
update.finalize();
update.id = add_update(update.clone()).await?;
Ok(update)
}
impl Resolve<WriteArgs> for RefreshBuildCache {
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<NoData> {
// Even though this is a write request, this doesn't change any config. Anyone that can execute the
// build should be able to do this.
let build = get_check_permissions::<Build>(
&self.build,
user,
PermissionLevel::Execute.into(),
)
.await?;
let repo = if !build.config.files_on_host
&& !build.config.linked_repo.is_empty()
{
crate::resource::get::<Repo>(&build.config.linked_repo)
.await?
.into()
} else {
None
};
let (_, latest_hash, latest_message, _) = git::clone(
clone_args,
&config.repo_directory,
access_token,
&[],
"",
None,
)
.await
.context("failed to clone build repo")?;
let RemoteDockerfileContents {
path,
contents,
error,
hash,
message,
} = if build.config.files_on_host {
// =============
// FILES ON HOST
// =============
match get_on_host_dockerfile(&build).await {
Ok(FileContents { path, contents }) => {
RemoteDockerfileContents {
path: Some(path),
contents: Some(contents),
..Default::default()
}
}
Err(e) => RemoteDockerfileContents {
error: Some(format_serror(&e.into())),
..Default::default()
},
}
} else if let Some(repo) = &repo {
let Some(res) = get_git_remote(&build, repo.into()).await?
else {
// Nothing to do here
return Ok(NoData {});
};
res
} else if !build.config.repo.is_empty() {
let Some(res) = get_git_remote(&build, (&build).into()).await?
else {
// Nothing to do here
return Ok(NoData {});
};
res
} else {
// =============
// UI BASED FILE
// =============
RemoteDockerfileContents::default()
};
let info = BuildInfo {
last_built_at: build.info.last_built_at,
built_hash: build.info.built_hash,
built_message: build.info.built_message,
latest_hash,
latest_message,
built_contents: build.info.built_contents,
remote_path: path,
remote_contents: contents,
remote_error: error,
latest_hash: hash,
latest_message: message,
};
let info = to_document(&info)
.context("failed to serialize build info to bson")?;
db_client()
.await
.builds
.update_one(
doc! { "name": &build.name },
@@ -156,196 +458,147 @@ impl Resolve<RefreshBuildCache, User> for State {
.await
.context("failed to update build info on db")?;
if repo_dir.exists() {
if let Err(e) = std::fs::remove_dir_all(&repo_dir) {
warn!("failed to remove build cache update repo directory | {e:?}")
}
}
Ok(NoData {})
}
}
impl Resolve<CreateBuildWebhook, User> for State {
#[instrument(name = "CreateBuildWebhook", skip(self, user))]
async fn resolve(
&self,
CreateBuildWebhook { build }: CreateBuildWebhook,
user: User,
) -> anyhow::Result<CreateBuildWebhookResponse> {
let Some(github) = github_client() else {
return Err(anyhow!(
"github_webhook_app is not configured in core config toml"
));
};
async fn get_on_host_periphery(
build: &Build,
) -> anyhow::Result<PeripheryClient> {
if build.config.builder_id.is_empty() {
return Err(anyhow!("No builder associated with build"));
}
let build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Write,
)
.await?;
let builder = resource::get::<Builder>(&build.config.builder_id)
.await
.context("Failed to get builder")?;
if build.config.repo.is_empty() {
return Err(anyhow!(
"No repo configured, can't create webhook"
));
match builder.config {
BuilderConfig::Aws(_) => {
Err(anyhow!("Files on host doesn't work with AWS builder"))
}
let mut split = build.config.repo.split('/');
let owner = split.next().context("Build repo has no owner")?;
let Some(github) = github.get(owner) else {
return Err(anyhow!(
"Cannot manage repo webhooks under owner {owner}"
));
};
let repo =
split.next().context("Build repo has no repo after the /")?;
let github_repos = github.repos();
// First make sure the webhook isn't already created (inactive ones are ignored)
let webhooks = github_repos
.list_all_webhooks(owner, repo)
.await
.context("failed to list all webhooks on repo")?
.body;
let CoreConfig {
host,
webhook_base_url,
webhook_secret,
..
} = core_config();
let webhook_secret = if build.config.webhook_secret.is_empty() {
webhook_secret
} else {
&build.config.webhook_secret
};
let host = webhook_base_url.as_ref().unwrap_or(host);
let url = format!("{host}/listener/github/build/{}", build.id);
for webhook in webhooks {
if webhook.active && webhook.config.url == url {
return Ok(NoData {});
BuilderConfig::Url(config) => {
// TODO: Ensure connection is actually established.
// Builder id no good because it may be active for multiple connections.
let periphery = PeripheryClient::new(
PeripheryConnectionArgs::from_url_builder(
&ObjectId::new().to_hex(),
&config,
),
config.insecure_tls,
)
.await?;
// Poll for connection to be estalished
let mut err = None;
for _ in 0..10 {
tokio::time::sleep(Duration::from_secs(1)).await;
match periphery.health_check().await {
Ok(_) => return Ok(periphery),
Err(e) => err = Some(e),
};
}
Err(err.context("Missing error")?)
}
BuilderConfig::Server(config) => {
if config.server_id.is_empty() {
return Err(anyhow!(
"Builder is type server, but has no server attached"
));
}
let (server, state) =
get_server_with_state(&config.server_id).await?;
if state != ServerState::Ok {
return Err(anyhow!(
"Builder server is disabled or not reachable"
));
};
periphery_client(&server).await
}
}
}
// Now good to create the webhook
let request = ReposCreateWebhookRequest {
active: Some(true),
config: Some(ReposCreateWebhookRequestConfig {
url,
secret: webhook_secret.to_string(),
content_type: String::from("json"),
insecure_ssl: None,
digest: Default::default(),
token: Default::default(),
}),
events: vec![String::from("push")],
name: String::from("web"),
};
github_repos
.create_webhook(owner, repo, &request)
.await
.context("failed to create webhook")?;
/// The successful case will be included as Some(remote_contents).
/// The error case will be included as Some(remote_error)
async fn get_on_host_dockerfile(
build: &Build,
) -> anyhow::Result<FileContents> {
get_on_host_periphery(build)
.await?
.request(GetDockerfileContentsOnHost {
name: build.name.clone(),
build_path: build.config.build_path.clone(),
dockerfile_path: build.config.dockerfile_path.clone(),
})
.await
}
if !build.config.webhook_enabled {
self
.resolve(
UpdateBuild {
id: build.id,
config: PartialBuildConfig {
webhook_enabled: Some(true),
..Default::default()
},
},
user,
)
async fn get_git_remote(
build: &Build,
mut clone_args: RepoExecutionArgs,
) -> anyhow::Result<Option<RemoteDockerfileContents>> {
if clone_args.provider.is_empty() {
// Nothing to do here
return Ok(None);
}
let config = core_config();
let repo_path = clone_args.unique_path(&config.repo_directory)?;
clone_args.destination = Some(repo_path.display().to_string());
let access_token = if let Some(username) = &clone_args.account {
git_token(&clone_args.provider, username, |https| {
clone_args.https = https
})
.await
.context("failed to update build to enable webhook")?;
}
.with_context(
|| format!("Failed to get git token in call to db. Stopping run. | {} | {username}", clone_args.provider),
)?
} else {
None
};
Ok(NoData {})
let (res, _) = git::pull_or_clone(
clone_args,
&config.repo_directory,
access_token,
)
.await
.context("Failed to clone Build repo")?;
// Ensure clone / pull successful,
// propogate error log -> 'errored' and return.
if let Some(failure) = res.logs.iter().find(|log| !log.success) {
return Ok(Some(RemoteDockerfileContents {
path: Some(format!("Failed at: {}", failure.stage)),
error: Some(failure.combined()),
..Default::default()
}));
}
let relative_path = PathBuf::from(&build.config.build_path)
.join(&build.config.dockerfile_path);
let full_path = repo_path.join(&relative_path);
let (contents, error) =
match fs::read_to_string(&full_path).await.with_context(|| {
format!("Failed to read dockerfile contents at {full_path:?}")
}) {
Ok(contents) => (Some(contents), None),
Err(e) => (None, Some(format_serror(&e.into()))),
};
Ok(Some(RemoteDockerfileContents {
path: Some(relative_path.display().to_string()),
contents,
error,
hash: res.commit_hash,
message: res.commit_message,
}))
}
impl Resolve<DeleteBuildWebhook, User> for State {
#[instrument(name = "DeleteBuildWebhook", skip(self, user))]
async fn resolve(
&self,
DeleteBuildWebhook { build }: DeleteBuildWebhook,
user: User,
) -> anyhow::Result<DeleteBuildWebhookResponse> {
let Some(github) = github_client() else {
return Err(anyhow!(
"github_webhook_app is not configured in core config toml"
));
};
let build = resource::get_check_permissions::<Build>(
&build,
&user,
PermissionLevel::Write,
)
.await?;
if build.config.git_provider != "github.com" {
return Err(anyhow!(
"Can only manage github.com repo webhooks"
));
}
if build.config.repo.is_empty() {
return Err(anyhow!(
"No repo configured, can't delete webhook"
));
}
let mut split = build.config.repo.split('/');
let owner = split.next().context("Build repo has no owner")?;
let Some(github) = github.get(owner) else {
return Err(anyhow!(
"Cannot manage repo webhooks under owner {owner}"
));
};
let repo =
split.next().context("Build repo has no repo after the /")?;
let github_repos = github.repos();
let webhooks = github_repos
.list_all_webhooks(owner, repo)
.await
.context("failed to list all webhooks on repo")?
.body;
let CoreConfig {
host,
webhook_base_url,
..
} = core_config();
let host = webhook_base_url.as_ref().unwrap_or(host);
let url = format!("{host}/listener/github/build/{}", build.id);
for webhook in webhooks {
if webhook.active && webhook.config.url == url {
github_repos
.delete_webhook(owner, repo, webhook.id)
.await
.context("failed to delete webhook")?;
return Ok(NoData {});
}
}
// No webhook to delete, all good
Ok(NoData {})
}
#[derive(Default)]
pub struct RemoteDockerfileContents {
pub path: Option<String>,
pub contents: Option<String>,
pub error: Option<String>,
pub hash: Option<String>,
pub message: Option<String>,
}

View File

@@ -1,59 +1,111 @@
use monitor_client::{
use komodo_client::{
api::write::*,
entities::{
builder::Builder, permission::PermissionLevel, user::User,
builder::Builder, permission::PermissionLevel, update::Update,
},
};
use resolver_api::Resolve;
use crate::{resource, state::State};
use crate::{permission::get_check_permissions, resource};
impl Resolve<CreateBuilder, User> for State {
#[instrument(name = "CreateBuilder", skip(self, user))]
use super::WriteArgs;
impl Resolve<WriteArgs> for CreateBuilder {
#[instrument(
"CreateBuilder",
skip_all,
fields(
operator = user.id,
builder = self.name,
config = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
&self,
CreateBuilder { name, config }: CreateBuilder,
user: User,
) -> anyhow::Result<Builder> {
resource::create::<Builder>(&name, config, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
resource::create::<Builder>(&self.name, self.config, None, user)
.await
}
}
impl Resolve<CopyBuilder, User> for State {
#[instrument(name = "CopyBuilder", skip(self, user))]
impl Resolve<WriteArgs> for CopyBuilder {
#[instrument(
"CopyBuilder",
skip_all,
fields(
operator = user.id,
builder = self.name,
copy_builder = self.id,
)
)]
async fn resolve(
&self,
CopyBuilder { name, id }: CopyBuilder,
user: User,
) -> anyhow::Result<Builder> {
let Builder { config, .. } = resource::get_check_permissions::<
Builder,
>(
&id, &user, PermissionLevel::Write
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
let Builder { config, .. } = get_check_permissions::<Builder>(
&self.id,
user,
PermissionLevel::Write.into(),
)
.await?;
resource::create::<Builder>(&name, config.into(), &user).await
resource::create::<Builder>(&self.name, config.into(), None, user)
.await
}
}
impl Resolve<DeleteBuilder, User> for State {
#[instrument(name = "DeleteBuilder", skip(self, user))]
impl Resolve<WriteArgs> for DeleteBuilder {
#[instrument(
"DeleteBuilder",
skip_all,
fields(
operator = user.id,
builder = self.id,
)
)]
async fn resolve(
&self,
DeleteBuilder { id }: DeleteBuilder,
user: User,
) -> anyhow::Result<Builder> {
resource::delete::<Builder>(&id, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
Ok(resource::delete::<Builder>(&self.id, user).await?)
}
}
impl Resolve<UpdateBuilder, User> for State {
#[instrument(name = "UpdateBuilder", skip(self, user))]
impl Resolve<WriteArgs> for UpdateBuilder {
#[instrument(
"UpdateBuilder",
skip_all,
fields(
operator = user.id,
builder = self.id,
update = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
&self,
UpdateBuilder { id, config }: UpdateBuilder,
user: User,
) -> anyhow::Result<Builder> {
resource::update::<Builder>(&id, config, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Builder> {
Ok(
resource::update::<Builder>(&self.id, self.config, user)
.await?,
)
}
}
impl Resolve<WriteArgs> for RenameBuilder {
#[instrument(
"RenameBuilder",
skip_all,
fields(
operator = user.id,
builder = self.id,
new_name = self.name
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Update> {
Ok(resource::rename::<Builder>(&self.id, &self.name, user).await?)
}
}

View File

@@ -1,19 +1,22 @@
use anyhow::{anyhow, Context};
use monitor_client::{
use anyhow::{Context, anyhow};
use database::mungos::{by_id::update_one_by_id, mongodb::bson::doc};
use komodo_client::{
api::write::*,
entities::{
deployment::{Deployment, DeploymentState},
monitor_timestamp,
permission::PermissionLevel,
server::Server,
to_monitor_name,
update::Update,
user::User,
Operation,
deployment::{
Deployment, DeploymentImage, DeploymentState,
PartialDeploymentConfig, RestartMode,
},
docker::container::RestartPolicyNameEnum,
komodo_timestamp,
permission::PermissionLevel,
server::{Server, ServerState},
to_container_compatible_name,
update::Update,
},
};
use mungos::{by_id::update_one_by_id, mongodb::bson::doc};
use periphery_client::api;
use periphery_client::api::{self, container::InspectContainer};
use resolver_api::Resolve;
use crate::{
@@ -22,72 +25,226 @@ use crate::{
query::get_deployment_state,
update::{add_update, make_update},
},
permission::get_check_permissions,
resource,
state::{action_states, db_client, State},
state::{action_states, db_client, server_status_cache},
};
impl Resolve<CreateDeployment, User> for State {
#[instrument(name = "CreateDeployment", skip(self, user))]
use super::WriteArgs;
impl Resolve<WriteArgs> for CreateDeployment {
#[instrument(
"CreateDeployment",
skip_all,
fields(
operator = user.id,
deployment = self.name,
config = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
&self,
CreateDeployment { name, config }: CreateDeployment,
user: User,
) -> anyhow::Result<Deployment> {
resource::create::<Deployment>(&name, config, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
resource::create::<Deployment>(
&self.name,
self.config,
None,
user,
)
.await
}
}
impl Resolve<CopyDeployment, User> for State {
#[instrument(name = "CopyDeployment", skip(self, user))]
impl Resolve<WriteArgs> for CopyDeployment {
#[instrument(
"CopyDeployment",
skip_all,
fields(
operator = user.id,
deployment = self.name,
copy_deployment = self.id,
)
)]
async fn resolve(
&self,
CopyDeployment { name, id }: CopyDeployment,
user: User,
) -> anyhow::Result<Deployment> {
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
let Deployment { config, .. } =
resource::get_check_permissions::<Deployment>(
&id,
&user,
PermissionLevel::Write,
get_check_permissions::<Deployment>(
&self.id,
user,
PermissionLevel::Read.into(),
)
.await?;
resource::create::<Deployment>(&name, config.into(), &user).await
resource::create::<Deployment>(
&self.name,
config.into(),
None,
user,
)
.await
}
}
impl Resolve<DeleteDeployment, User> for State {
#[instrument(name = "DeleteDeployment", skip(self, user))]
impl Resolve<WriteArgs> for CreateDeploymentFromContainer {
#[instrument(
"CreateDeploymentFromContainer",
skip_all,
fields(
operator = user.id,
server = self.server,
deployment = self.name,
)
)]
async fn resolve(
&self,
DeleteDeployment { id }: DeleteDeployment,
user: User,
) -> anyhow::Result<Deployment> {
resource::delete::<Deployment>(&id, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
let server = get_check_permissions::<Server>(
&self.server,
user,
PermissionLevel::Read.inspect().attach(),
)
.await?;
let cache = server_status_cache()
.get_or_insert_default(&server.id)
.await;
if cache.state != ServerState::Ok {
return Err(
anyhow!(
"Cannot inspect container: server is {:?}",
cache.state
)
.into(),
);
}
let container = periphery_client(&server)
.await?
.request(InspectContainer {
name: self.name.clone(),
})
.await
.context("Failed to inspect container")?;
let mut config = PartialDeploymentConfig {
server_id: server.id.into(),
..Default::default()
};
if let Some(container_config) = container.config {
config.image = container_config
.image
.map(|image| DeploymentImage::Image { image });
config.command = container_config.cmd.join(" ").into();
config.environment = container_config
.env
.into_iter()
.map(|env| format!(" {env}"))
.collect::<Vec<_>>()
.join("\n")
.into();
config.labels = container_config
.labels
.into_iter()
.map(|(key, val)| format!(" {key}: {val}"))
.collect::<Vec<_>>()
.join("\n")
.into();
}
if let Some(host_config) = container.host_config {
config.volumes = host_config
.binds
.into_iter()
.map(|bind| format!(" {bind}"))
.collect::<Vec<_>>()
.join("\n")
.into();
config.network = host_config.network_mode;
config.ports = host_config
.port_bindings
.into_iter()
.filter_map(|(container, mut host)| {
let host = host.pop()?.host_port?;
Some(format!(" {host}:{}", container.replace("/tcp", "")))
})
.collect::<Vec<_>>()
.join("\n")
.into();
config.restart = host_config.restart_policy.map(|restart| {
match restart.name {
RestartPolicyNameEnum::Always => RestartMode::Always,
RestartPolicyNameEnum::No
| RestartPolicyNameEnum::Empty => RestartMode::NoRestart,
RestartPolicyNameEnum::UnlessStopped => {
RestartMode::UnlessStopped
}
RestartPolicyNameEnum::OnFailure => RestartMode::OnFailure,
}
});
}
resource::create::<Deployment>(&self.name, config, None, user)
.await
}
}
impl Resolve<UpdateDeployment, User> for State {
#[instrument(name = "UpdateDeployment", skip(self, user))]
impl Resolve<WriteArgs> for DeleteDeployment {
#[instrument(
"DeleteDeployment",
skip_all,
fields(
operator = user.id,
deployment = self.id
)
)]
async fn resolve(
&self,
UpdateDeployment { id, config }: UpdateDeployment,
user: User,
) -> anyhow::Result<Deployment> {
resource::update::<Deployment>(&id, config, &user).await
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
Ok(resource::delete::<Deployment>(&self.id, user).await?)
}
}
impl Resolve<RenameDeployment, User> for State {
#[instrument(name = "RenameDeployment", skip(self, user))]
impl Resolve<WriteArgs> for UpdateDeployment {
#[instrument(
"UpdateDeployment",
skip_all,
fields(
operator = user.id,
deployment = self.id,
update = serde_json::to_string(&self.config).unwrap(),
)
)]
async fn resolve(
&self,
RenameDeployment { id, name }: RenameDeployment,
user: User,
) -> anyhow::Result<Update> {
let deployment = resource::get_check_permissions::<Deployment>(
&id,
&user,
PermissionLevel::Write,
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Deployment> {
Ok(
resource::update::<Deployment>(&self.id, self.config, user)
.await?,
)
}
}
impl Resolve<WriteArgs> for RenameDeployment {
#[instrument(
"RenameDeployment",
skip_all,
fields(
operator = user.id,
deployment = self.id,
new_name = self.name,
)
)]
async fn resolve(
self,
WriteArgs { user }: &WriteArgs,
) -> serror::Result<Update> {
let deployment = get_check_permissions::<Deployment>(
&self.id,
user,
PermissionLevel::Write.into(),
)
.await?;
@@ -102,53 +259,57 @@ impl Resolve<RenameDeployment, User> for State {
let _action_guard =
action_state.update(|state| state.renaming = true)?;
let name = to_monitor_name(&name);
let name = to_container_compatible_name(&self.name);
let container_state = get_deployment_state(&deployment).await?;
let container_state =
get_deployment_state(&deployment.id).await?;
if container_state == DeploymentState::Unknown {
return Err(anyhow!(
"cannot rename deployment when container status is unknown"
));
return Err(
anyhow!(
"Cannot rename Deployment when container status is unknown"
)
.into(),
);
}
let mut update =
make_update(&deployment, Operation::RenameDeployment, &user);
make_update(&deployment, Operation::RenameDeployment, user);
update_one_by_id(
&db_client().await.deployments,
&db_client().deployments,
&deployment.id,
mungos::update::Update::Set(
doc! { "name": &name, "updated_at": monitor_timestamp() },
database::mungos::update::Update::Set(
doc! { "name": &name, "updated_at": komodo_timestamp() },
),
None,
)
.await
.context("failed to update deployment name on db")?;
.context("Failed to update Deployment name on db")?;
if container_state != DeploymentState::NotDeployed {
let server =
resource::get::<Server>(&deployment.config.server_id).await?;
let log = periphery_client(&server)?
let log = periphery_client(&server)
.await?
.request(api::container::RenameContainer {
curr_name: deployment.name.clone(),
new_name: name.clone(),
})
.await
.context("failed to rename container on server")?;
.context("Failed to rename container on server")?;
update.logs.push(log);
}
update.push_simple_log(
"rename deployment",
"Rename Deployment",
format!(
"renamed deployment from {} to {}",
"Renamed Deployment from {} to {}",
deployment.name, name
),
);
update.finalize();
add_update(update.clone()).await?;
update.id = add_update(update.clone()).await?;
Ok(update)
}

Some files were not shown because too many files have changed in this diff Show More