4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | 𝐤𝐮𝐫𝐯 is a process manager, mainly for Node.js and Python applications. It's written in Rust
. It daemonizes your apps so that they can run in the background. It also restarts them if they crash.
8 |
9 |
10 |
11 |
12 | Docs • Crate • Readme
13 |
14 |
15 |
16 |
17 |
18 | > [!WARNING]
19 | > Heads up, this project is my Rust-learning playground and not production-ready yet:
20 | >
21 | > - I built this because my apps needed a process manager, and I had an itch to learn Rust. So, here it is... my first Rust project!
22 | > - No tests yet (oops!)
23 | > - Tested only on Windows 11
24 | > - Rust newbie alert! 🚨
25 | > - Using it for my own projects, but not on a grand scale
26 |
27 |
28 | ## Why 𝐤𝐮𝐫𝐯?
29 |
30 |
31 |
32 | So, why the name 𝐤𝐮𝐫𝐯? Well, it means "basket" in many languages I don't speak, like Norwegian (but it sounded cool 😄). Think of 𝐤𝐮𝐫𝐯 as a basket for your apps. In kurv, we call each deployed app as an `egg`. So, let's go and collect some eggs 🥚 in your basket 🧺.
33 |
34 |
35 | ## Installation
36 |
37 | > [!NOTE]
38 | > 𝐤𝐮𝐫𝐯 can run either as a server or as a CLI client, using the same binary.
39 | >
40 | > The server is responsible for managing the eggs, while the client is used to interact with the server and tell it what to do or ask it for information.
41 |
42 | ### Download binaries
43 |
44 | Download the latest release [from GitHub](https://github.com/lucas-labs/kurv/releases).
45 |
46 | ### crates.io
47 |
48 | You can also install it from [crates.io](https://crates.io/crates/kurv) using `cargo`:
49 |
50 | ```bash
51 | cargo install kurv
52 | ```
53 |
54 | ## Usage
55 |
56 | 
57 |
58 |
59 | ### Start the server
60 |
61 | To get the server rolling, type:
62 |
63 | ```bash
64 | kurv server
65 | ```
66 |
67 | > [!IMPORTANT]
68 | > - 𝐤𝐮𝐫𝐯 will create a file called `.kurv` where it will store the current
69 | > state of the server. The file will be created in the same directory where
70 | > the binary is located or in the path specified by the `KURV_HOME_KEY`
71 | > environment variable.
72 | >
73 | > - since 𝐤𝐮𝐫𝐯 can be used both as a server and as a client, if you want
74 | > to run it as a server, you need to set the `KURV_SERVER` environment
75 | > to `true`. This is just a safety measure to prevent you from running
76 | > the server when you actually want to run the client.
77 | > To bypass this, you can use the `--force` flag (`kurv server --force`)
78 |
79 | ### Collect some 🥚
80 | To deploy/start/daemonize an app (collect an egg), do:
81 |
82 | ```bash
83 | kurv collect
84 | ```
85 |
86 | The path should point to a YAML file that contains the configuration for the egg.
87 |
88 | It should look something like this:
89 |
90 | ```yaml title="myegg.kurv"
91 | name: fastapi # the name of the egg / should be unique
92 | command: poetry # the command/program to run
93 | args: # the arguments to pass to the command
94 | - run
95 | - serve
96 | cwd: /home/user/my-fastapi-app # the working directory in which the command will be run
97 | env: # the environment variables to pass to the command
98 | FASTAPI_PORT: 8080
99 | ```
100 |
101 | This will run the command `poetry run serve` in `/home/user/my-fastapi-app` with the environment variable `FASTAPI_PORT` set to `8080`.
102 |
103 | If for some reason, the command/program crashes or exits, 𝐤𝐮𝐫𝐯 will revive it!
104 |
105 | ### Show me my eggs
106 |
107 | If you want a summary of the current state of your eggs, run:
108 |
109 | ```zsh
110 | $ kurv list
111 |
112 | 🥚 eggs snapshot
113 |
114 | ╭───┬───────┬───────────┬─────────┬───┬────────╮
115 | │ # │ pid │ name │ status │ ↺ │ uptime │
116 | ├───┼───────┼───────────┼─────────┼───┼────────┤
117 | │ 1 │ 35824 │ fastapi │ running │ 0 │ 1s │
118 | │ 2 │ 0 │ fastapi-2 │ stopped │ 0 │ - │
119 | ╰───┴───────┴───────────┴─────────┴───┴────────╯
120 | ```
121 |
122 | For details on a specific egg:
123 |
124 | ``` sh
125 | $ kurv egg
126 | ```
127 |
128 | This will show you the egg's configuration, process details, etc.
129 |
130 | ### Stop an egg
131 |
132 | To halt an egg without removing it:
133 |
134 | ``` sh
135 | $ kurv stop
136 | ```
137 |
138 | This will stop the process but keep its configuration in the basket in case
139 | you want to start it again later.
140 |
141 | ### Remove an egg
142 |
143 | To actually remove an egg, run:
144 |
145 | ``` sh
146 | $ kurv remove
147 | ```
148 |
149 | It will stop the process and remove the egg from the basket.
150 |
151 | ### Restart
152 |
153 | If you need the process to be restarted, run:
154 |
155 | ``` sh
156 | $ kurv restart
157 | ```
158 |
159 | ### To do list
160 |
161 | 𝐤𝐮𝐫𝐯 is still under development. Here are some of the things I'm planning to add:
162 |
163 | - [ ] Simple password protection
164 | - [ ] Remotely manage eggs
165 | - [ ] SSL support
166 | - [ ] Handle cors correctly
167 |
168 | And last but not least:
169 |
170 | - [ ] Tests (I know, I know... 🤭)
171 |
172 | #### Plugins / extensions
173 |
174 | Since 𝐤𝐮𝐫𝐯 is a process manager, we can easily extend its functionality by adding
175 | plugin eggs (simple eggs managed by 𝐤𝐮𝐫𝐯 itself that provide additional functionality).
176 |
177 | Here are some ideas I have for plugins:
178 |
179 | - [ ] Web UI
180 | - [ ] Log Viewer
181 | - [ ] Log Rotation
182 |
183 | ### Inspiration
184 |
185 | #### pm2
186 | Inspired by the robust process manager, [pm2](https://pm2.keymetrics.io/), my goal with 𝐤𝐮𝐫𝐯 was to create a lightweight alternative. Not that pm2 is a resource hog, but I found myself in a server with extremely limited resources. Plus, I was itching for an excuse to dive into Rust, and voila, 𝐤𝐮𝐫𝐯 was born.
187 |
188 | #### eggsecutor
189 | Derived from [eggsecutor](https://github.com/lucas-labs/kurv), 𝐤𝐮𝐫𝐯 adopted the whimsical term "eggs" to represent deployed applications.
190 |
191 | #### pueue
192 | Insights from [pueue](https://github.com/Nukesor/pueue) were instrumental in helping me understand how to manage processes in Rust.
193 |
194 |
195 |
196 |
197 | -------
198 | With 🧉 from Argentina 🇦🇷
199 |
--------------------------------------------------------------------------------
/Taskfile.yaml:
--------------------------------------------------------------------------------
1 | # https://taskfile.dev
2 |
3 | version: '3'
4 |
5 | tasks:
6 | build:release:
7 | desc: ⚡ build kurv «release»
8 | cmds:
9 | - cargo build --release
10 | - python check_size.py
11 |
12 | build:
13 | desc: ⚡ build kurv «debug»
14 | cmds:
15 | - cargo build
16 | - python check_size.py
17 |
18 | # the following tasks are for testing the ci workflow locally, using the
19 | # nektosact.com tool
20 |
21 | local-ci-release:
22 | desc: 🚀 run local ci workflow «release»
23 | cmds:
24 | - |
25 | act push \
26 | -W="./.github/workflows/ci.yml" \
27 | -e="./.github/act-test/release.json" \
28 | --secret-file="./.github/act-test/secrets.env" \
29 | --pull=false
30 |
31 | local-ci-pub:
32 | desc: 🚀 run local ci workflow «publish»
33 | cmds:
34 | - |
35 | act release \
36 | -W="./.github/workflows/publish.yml" \
37 | -e="./.github/act-test/publish.json" \
38 | --secret-file="./.github/act-test/secrets.env" \
39 | --pull=false \
40 | --reuse
--------------------------------------------------------------------------------
/check_size.py:
--------------------------------------------------------------------------------
1 | """
2 | small script to run after build, to check if there was a significant
3 | change on executable size, compared to the previous build.
4 |
5 | this aims to detect unwanted big differences before it's too late
6 | """
7 |
8 |
9 | import os
10 | import pathlib
11 |
12 | curr_dir = pathlib.Path(os.getcwd())
13 | sizefile_path = pathlib.Path(curr_dir.joinpath('.task'))
14 |
15 |
16 | def bad(txt):
17 | return '\033[91m' + txt + '\033[0m'
18 |
19 |
20 | def good(txt):
21 | return '\033[92m' + txt + '\033[0m'
22 |
23 |
24 | def head(txt):
25 | return '\033[94m' + txt + '\033[0m'
26 |
27 |
28 | files = {
29 | 'release': curr_dir.joinpath('target/release/kurv.exe'),
30 | 'debug': curr_dir.joinpath('target/debug/kurv.exe'),
31 | }
32 |
33 | print("\n🥚 ⇝ exe file sizes change\n")
34 |
35 | for key, exe in files.items():
36 | if exe.is_file():
37 | sizefile = sizefile_path.joinpath(key)
38 | new_size: float = os.stat(exe).st_size / 1024
39 | old_size: float
40 |
41 | with open(sizefile, 'r') as f:
42 | old_size = float(f.read())
43 |
44 | # diff: str = f'{old_size:.0f}kb'
45 | diff = new_size - old_size
46 | diff_str = f"{'+' if diff > 0 else '=' if diff == 0 else ''}{diff:.0f}kb"
47 |
48 | fmt = bad if diff > 10 else good
49 |
50 | print(f'{key}: {fmt(diff_str)} (prev: {old_size}kb, now: {new_size}kb)')
51 |
52 | with open(sizefile, 'w') as f:
53 | f.write(f'{new_size}')
54 |
--------------------------------------------------------------------------------
/src/api/eggs.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::err,
3 | super::Context,
4 | crate::common::tcp::{json, Request, Response},
5 | crate::kurv::EggStatus,
6 | crate::{
7 | common::{duration::humanize_duration, str::ToString},
8 | kurv::{Egg, EggState},
9 | },
10 | anyhow::{anyhow, Result},
11 | serde::{Deserialize, Serialize},
12 | };
13 |
14 | #[derive(Serialize, Deserialize, Debug)]
15 | pub struct EggsSummaryList(pub Vec);
16 |
17 | #[derive(Serialize, Deserialize, Debug)]
18 | pub struct EggSummary {
19 | pub id: usize,
20 | pub pid: u32,
21 | pub name: String,
22 | pub status: EggStatus,
23 | pub uptime: String,
24 | pub retry_count: u32,
25 | }
26 |
27 | const WRONG_ID_MSG: &str = "missing or invalid egg id";
28 | const NOT_FOUND_MSG: &str = "egg not found";
29 |
30 | pub fn summary(_request: &Request, ctx: &Context) -> Result {
31 | let state = ctx.state.clone();
32 | let state = state.lock().map_err(|_| anyhow!("failed to lock state"))?;
33 | let eggs = state.eggs.clone();
34 | let mut summary_list = Vec::new();
35 |
36 | for (_, egg) in eggs.iter() {
37 | let summary = EggSummary {
38 | id: match egg.id {
39 | Some(ref id) => *id,
40 | None => 0,
41 | },
42 | pid: match egg.state {
43 | Some(ref state) => state.pid,
44 | None => 0,
45 | },
46 | name: egg.name.clone(),
47 | status: match egg.state {
48 | Some(ref state) => state.status,
49 | None => EggStatus::Pending,
50 | },
51 | uptime: match egg.state {
52 | Some(ref state) => {
53 | let start_time = state.start_time;
54 | if let Some(start_time) = start_time {
55 | let now = chrono::Utc::now();
56 | humanize_duration(now.signed_duration_since(start_time))
57 | } else {
58 | "-".to_string()
59 | }
60 | }
61 | None => "-".to_string(),
62 | },
63 | retry_count: match egg.state {
64 | Some(ref state) => state.try_count,
65 | None => 0,
66 | },
67 | };
68 |
69 | summary_list.push(summary);
70 | }
71 |
72 | Ok(json(200, EggsSummaryList(summary_list)))
73 | }
74 |
75 | pub fn get(request: &Request, ctx: &Context) -> Result {
76 | if let Some(token) = request.path_params.get("egg_id") {
77 | let state = ctx.state.clone();
78 | let state = state.lock().map_err(|_| anyhow!("failed to lock state"))?;
79 |
80 | let id = state.get_id_by_token(token);
81 |
82 | if let Some(id) = id {
83 | if let Some(egg) = state.get(id) {
84 | return Ok(json(200, egg.clone()));
85 | }
86 | }
87 |
88 | return Ok(err(404, format!("{}: {}", NOT_FOUND_MSG, token)));
89 | }
90 |
91 | Ok(err(400, WRONG_ID_MSG.to_string()))
92 | }
93 |
94 | /// stop a running egg
95 | pub fn stop(request: &Request, ctx: &Context) -> Result {
96 | set_status(request, ctx, EggStatus::Stopped)
97 | }
98 |
99 | /// start a running egg
100 | pub fn start(request: &Request, ctx: &Context) -> Result {
101 | set_status(request, ctx, EggStatus::Pending)
102 | }
103 |
104 | /// remove an egg
105 | pub fn remove(request: &Request, ctx: &Context) -> Result {
106 | set_status(request, ctx, EggStatus::PendingRemoval)
107 | }
108 |
109 | /// restart a running egg
110 | pub fn restart(request: &Request, ctx: &Context) -> Result {
111 | set_status(request, ctx, EggStatus::Restarting)
112 | }
113 |
114 | /// changes the status of an egg to Stopped or Pending
115 | pub fn set_status(request: &Request, ctx: &Context, status: EggStatus) -> Result {
116 | if let Some(token) = request.path_params.get("egg_id") {
117 | let state = ctx.state.clone();
118 | let mut state = state.lock().map_err(|_| anyhow!("failed to lock state"))?;
119 |
120 | let id = state.get_id_by_token(token);
121 |
122 | if let Some(id) = id {
123 | if let Some(egg) = state.get_mut(id) {
124 | match status {
125 | EggStatus::Pending => {
126 | // we can only change to pending if its state is currently Stopped
127 | if let Some(state) = egg.state.clone() {
128 | if state.status != EggStatus::Stopped {
129 | return Ok(err(
130 | 400,
131 | format!("egg {} is already running", egg.name),
132 | ));
133 | }
134 | }
135 | }
136 | EggStatus::Stopped => {}
137 | EggStatus::PendingRemoval => {}
138 | EggStatus::Restarting => {}
139 | _ => {
140 | let trim: &[_] = &['\r', '\n'];
141 | return Ok(err(
142 | 400,
143 | format!(
144 | "can't change status to '{}'",
145 | status.str().trim_matches(trim)
146 | ),
147 | ));
148 | }
149 | };
150 |
151 | egg.set_status(status);
152 | return Ok(json(200, egg.clone()));
153 | }
154 | }
155 |
156 | return Ok(err(404, format!("{}: {}", NOT_FOUND_MSG, token)));
157 | }
158 |
159 | Ok(err(400, WRONG_ID_MSG.to_string()))
160 | }
161 |
162 | /// changes the status of an egg to Stopped or Pending
163 | pub fn collect(request: &Request, ctx: &Context) -> Result {
164 | let maybe_egg: Result = serde_json::from_str(&request.body);
165 |
166 | match maybe_egg {
167 | Ok(mut egg) => {
168 | let state = ctx.state.clone();
169 | let mut state = state.lock().map_err(|_| anyhow!("failed to lock state"))?;
170 |
171 | if state.contains_key(egg.name.clone()) {
172 | return Ok(err(
173 | 409,
174 | format!("An egg with name {} already exists", egg.name.clone()),
175 | ));
176 | }
177 |
178 | // set egg state as pendig
179 | let egg_state = match egg.state.clone() {
180 | Some(state) => {
181 | let mut new_state = state.clone();
182 | new_state.status = EggStatus::Pending;
183 |
184 | new_state
185 | }
186 | None => EggState {
187 | status: EggStatus::Pending,
188 | start_time: None,
189 | try_count: 0,
190 | error: None,
191 | pid: 0,
192 | },
193 | };
194 |
195 | egg.state = Some(egg_state);
196 | let id = state.collect(&egg);
197 | egg.id = Some(id);
198 |
199 | Ok(json(200, egg))
200 | }
201 | Err(error) => Ok(err(400, format!("Invalid egg: {}", error))),
202 | }
203 | }
204 |
--------------------------------------------------------------------------------
/src/api/err.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::Context,
3 | crate::common::tcp::{json, ErrorResponse, Request, Response},
4 | anyhow::Result,
5 | };
6 |
7 | /// handle not allowed requests
8 | pub fn not_allowed(_request: &Request, _ctx: &Context) -> Result {
9 | Ok(json(
10 | 405,
11 | ErrorResponse {
12 | code: 405,
13 | status: "Method Not Allowed".to_string(),
14 | message: "The method specified in the request is not allowed.".to_string(),
15 | },
16 | ))
17 | }
18 |
--------------------------------------------------------------------------------
/src/api/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod eggs;
2 | pub mod err;
3 | pub mod status;
4 |
5 | use {
6 | crate::common::tcp::{err, handle as handle_tcp, Handler, Request, Response},
7 | crate::kurv::{InfoMtx, KurvStateMtx},
8 | anyhow::Result,
9 | log::info,
10 | std::net::TcpListener,
11 | };
12 |
13 | pub struct Context {
14 | pub state: KurvStateMtx,
15 | pub info: InfoMtx,
16 | }
17 |
18 | type RouteHandler = fn(&Request, &Context) -> Result;
19 | type RouteRegex = &'static str;
20 | type RouteMethod = &'static str;
21 | type RouteDef = (RouteMethod, RouteRegex, RouteHandler);
22 |
23 | struct Router {
24 | info: InfoMtx,
25 | state: KurvStateMtx,
26 | }
27 |
28 | impl Router {
29 | /// returns a list of routes which are composed of a method and a regex path
30 | fn routes(&self) -> Vec {
31 | vec![
32 | ("GET", "/", status::status),
33 | ("GET", "/status", status::status),
34 | ("GET", "/eggs", eggs::summary),
35 | ("POST", "/eggs", eggs::collect),
36 | ("POST", "/eggs/(?P.*)/stop", eggs::stop),
37 | ("POST", "/eggs/(?P.*)/start", eggs::start),
38 | ("POST", "/eggs/(?P.*)/restart", eggs::restart),
39 | ("POST", "/eggs/(?P.*)/remove", eggs::remove),
40 | ("POST", "/eggs/(?P.*)/egg", eggs::remove),
41 | ("GET", "/eggs/(?P.*)", eggs::get),
42 | (".*", ".*", err::not_allowed), // last resort
43 | ]
44 | }
45 |
46 | fn compiled_routes(&self) -> Vec<(regex_lite::Regex, RouteHandler)> {
47 | self.routes()
48 | .iter()
49 | .map(|&(method, regex_raw, handler)| {
50 | let route_re = regex_lite::Regex::new(format!("^{method} {regex_raw}/?$").as_str())
51 | .expect("Invalid regex pattern on route");
52 | (route_re, handler)
53 | })
54 | .collect()
55 | }
56 | }
57 |
58 | impl Handler for Router {
59 | fn handle(&self, request: &mut Request) -> Response {
60 | let method = request.method.as_str();
61 | let path = request.path.as_str();
62 | // let mut request = request.clone();
63 | let compiled_routes = self.compiled_routes();
64 |
65 | let mut result = err(500, "internal server error".to_string());
66 |
67 | for (route_re, handler) in compiled_routes {
68 | let route = format!("{method} {path}");
69 | let route_str = route.as_str();
70 | let names = route_re.capture_names().flatten();
71 |
72 | if let Some(capture) = route_re.captures(route_str) {
73 | for key in names {
74 | let value = capture.name(key).map(|v| v.as_str()).unwrap_or("");
75 | request.path_params.insert(key.to_string(), value.to_string());
76 | }
77 |
78 | let ctx = Context {
79 | state: self.state.clone(),
80 | info: self.info.clone(),
81 | };
82 | result = match handler(request, &ctx) {
83 | Ok(response) => response,
84 | Err(e) => err(500, format!("{}", e)),
85 | };
86 | break;
87 | }
88 | }
89 |
90 | result
91 | }
92 | }
93 |
94 | /// starts the api server
95 | pub fn start(info: InfoMtx, state: KurvStateMtx) {
96 | let host = std::env::var("KURV_API_HOST").unwrap_or("127.0.0.1".to_string());
97 | let port = std::env::var("KURV_API_PORT").unwrap_or("58787".to_string());
98 | let listener = TcpListener::bind(format!("{}:{}", host, port)).unwrap();
99 |
100 | info!(
101 | "kurv api listening on http://{}:{}/ ",
102 | host, port
103 | );
104 |
105 | let router = Router { info, state };
106 |
107 | for stream in listener.incoming() {
108 | let stream = stream.unwrap();
109 | handle_tcp(stream, &router);
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/api/status.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::Context,
3 | crate::common::tcp::{json, Request, Response},
4 | anyhow::Result,
5 | };
6 |
7 | pub fn status(_request: &Request, ctx: &Context) -> Result {
8 | let info = ctx.info.clone();
9 | let info = info.lock().unwrap();
10 |
11 | Ok(json(200, info.clone()))
12 | }
13 |
--------------------------------------------------------------------------------
/src/cli/cmd/api/eggs.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::{parse_response, Api, ParsedResponse},
3 | crate::{api, kurv::Egg, printth},
4 | anyhow::Result,
5 | api::eggs::EggsSummaryList,
6 | std::process::exit,
7 | };
8 |
9 | impl Api {
10 | pub fn eggs_summary(&self) -> Result {
11 | let response = self.get("/eggs")?;
12 | let eggs_summary_list: EggsSummaryList = serde_json::from_str(&response.body)?;
13 |
14 | Ok(eggs_summary_list)
15 | }
16 |
17 | pub fn egg(&self, id: &str) -> Result {
18 | let response = self.get(format!("/eggs/{}", id).as_ref())?;
19 | let maybe_egg: ParsedResponse = parse_response(&response)?;
20 |
21 | match maybe_egg {
22 | ParsedResponse::Failure(err) => {
23 | printth!("[err: {}] {}\n", err.code, err.message);
24 | exit(1)
25 | }
26 |
27 | ParsedResponse::Success(egg) => Ok(egg),
28 | }
29 | }
30 |
31 | pub fn eggs_post(&self, route: &str, body: &str) -> Result {
32 | let response = self.post(format!("/eggs{route}").as_ref(), body)?;
33 | let maybe_egg: ParsedResponse = parse_response(&response)?;
34 |
35 | match maybe_egg {
36 | ParsedResponse::Failure(err) => {
37 | printth!("[err: {}] {}\n", err.code, err.message);
38 | exit(1)
39 | }
40 |
41 | ParsedResponse::Success(egg) => Ok(egg),
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/src/cli/cmd/api/mod.rs:
--------------------------------------------------------------------------------
1 | mod eggs;
2 |
3 | use {
4 | crate::common::tcp::ErrorResponse,
5 | anyhow::{anyhow, Result},
6 | serde::Deserialize,
7 | std::io::{Read, Write},
8 | std::net::TcpStream,
9 | std::str,
10 | };
11 |
12 | // ApiResponse struct to hold response headers and body
13 | pub(crate) struct ApiResponse {
14 | #[allow(dead_code)]
15 | pub headers: String,
16 | pub body: String,
17 | }
18 |
19 | // Api struct with host and port fields
20 | pub struct Api {
21 | pub host: String,
22 | pub port: u16,
23 | }
24 |
25 | impl Api {
26 | pub fn new() -> Self {
27 | let host = std::env::var("KURV_API_HOST").unwrap_or("127.0.0.1".to_string());
28 | let port = std::env::var("KURV_API_PORT")
29 | .unwrap_or("58787".to_string())
30 | .parse::()
31 | .unwrap_or(5878);
32 |
33 | Api { host, port }
34 | }
35 |
36 | // Private helper method to perform HTTP request and get response
37 | fn request(&self, method: &str, path: &str, body: Option<&str>) -> Result {
38 | let mut stream = TcpStream::connect(format!("{}:{}", self.host, self.port))
39 | .map_err(|_| anyhow!("failed to connect to api server"))?;
40 |
41 | let body_str = match body {
42 | Some(b) => format!("Content-Length: {}\r\n\r\n{}", b.len(), b),
43 | None => String::from("\r\n"),
44 | };
45 |
46 | let request = format!(
47 | "{} {} HTTP/1.1\r\nHost: {}\r\n{}\r\n",
48 | method, path, self.host, body_str
49 | );
50 |
51 | stream
52 | .write_all(request.as_bytes())
53 | .map_err(|_| anyhow!("failed to write to api server"))?;
54 |
55 | let mut buffer = Vec::new();
56 | stream
57 | .read_to_end(&mut buffer)
58 | .map_err(|_| anyhow!("failed to read from api server"))?;
59 |
60 | let response_str = str::from_utf8(&buffer)
61 | .map_err(|_| anyhow!("failed to parse response from api server"))?;
62 |
63 | // Extract headers and body from the response string
64 | let mut header_body_split = response_str.split("\r\n\r\n");
65 | let headers = header_body_split.next().unwrap_or_default().to_string();
66 | let body = header_body_split.next().unwrap_or_default().to_string();
67 |
68 | Ok(ApiResponse { headers, body })
69 | }
70 |
71 | // Method to perform HTTP GET request
72 | pub(crate) fn get(&self, path: &str) -> Result {
73 | self.request("GET", path, None)
74 | }
75 |
76 | // Method to perform HTTP POST request
77 | pub(crate) fn post(&self, path: &str, body: &str) -> Result {
78 | self.request("POST", path, Some(body))
79 | }
80 |
81 | // Method to perform HTTP PUT request
82 | #[allow(dead_code)]
83 | pub(crate) fn put(&self, path: &str, body: &str) -> Result {
84 | self.request("PUT", path, Some(body))
85 | }
86 |
87 | // Method to perform HTTP DELETE request
88 | #[allow(dead_code)]
89 | pub(crate) fn delete(&self, path: &str) -> Result {
90 | self.request("DELETE", path, None)
91 | }
92 | }
93 |
94 | pub enum ParsedResponse {
95 | Success(T),
96 | Failure(ErrorResponse),
97 | }
98 |
99 | /// parses a response from the server api.
100 | ///
101 | /// It returns a `ParsedResponse` that can either be a success call of type `T`
102 | /// or a failure of type `ErrorResponse`
103 | pub fn parse_response<'a, T: Deserialize<'a>>(
104 | response: &'a ApiResponse,
105 | ) -> Result> {
106 | let maybe_egg: Result = serde_json::from_str(response.body.as_str());
107 |
108 | match maybe_egg {
109 | Ok(parsed) => Ok(ParsedResponse::Success(parsed)),
110 | Err(_) => {
111 | // try to parse it as an ErrorResponse.
112 | let maybe_err_resp: Result =
113 | serde_json::from_str(response.body.as_str());
114 |
115 | match maybe_err_resp {
116 | Ok(parsed) => Ok(ParsedResponse::Failure(parsed)),
117 | Err(_) => Err(anyhow!("couldn't parse kurv server response")),
118 | }
119 | }
120 | }
121 | }
122 |
--------------------------------------------------------------------------------
/src/cli/cmd/collect.rs:
--------------------------------------------------------------------------------
1 | use crate::cli::cmd::wants_raw;
2 |
3 | use {
4 | crate::kurv::Egg,
5 | anyhow::anyhow,
6 | indoc::formatdoc,
7 | std::{path::PathBuf, process::exit},
8 | };
9 |
10 | use {
11 | crate::cli::{
12 | cmd::{api::Api, is_option_or_flag, wants_help},
13 | components::{Component, Help},
14 | },
15 | crate::printth,
16 | anyhow::Result,
17 | indoc::indoc,
18 | pico_args::Arguments,
19 | };
20 |
21 | /// collects a new egg
22 | pub fn run(args: &mut Arguments) -> Result<()> {
23 | if wants_help(args) {
24 | return help();
25 | }
26 |
27 | let api = Api::new();
28 | let cmd_arg: Result> =
29 | args.opt_free_from_str().map_err(|_| anyhow!("wrong usage"));
30 |
31 | match cmd_arg {
32 | Ok(maybe_arg) => {
33 | if let Some(path) = maybe_arg {
34 | if is_option_or_flag(&path) {
35 | return Err(anyhow!("wrong usage"));
36 | }
37 |
38 | printth!("\n⬮ collecting new egg \n");
39 |
40 | match Egg::load(PathBuf::from(path)) {
41 | Ok(egg) => {
42 | let body = serde_json::to_string(&egg).unwrap();
43 |
44 | // call the api
45 | let response = api.eggs_post("", body.as_ref());
46 |
47 | // check response
48 | if let Ok(egg) = response {
49 | if wants_raw(args) {
50 | printth!("{}", serde_json::to_string_pretty(&egg)?);
51 | return Ok(());
52 | }
53 |
54 | printth!(
55 | "{}",
56 | formatdoc! {
57 | "egg {} has been collected with id {} and
58 | scheduled to be started
59 |
60 | i you can check its status by running:
61 | $ kurv egg {}
62 | ",
63 | egg.name,
64 | egg.id.unwrap_or(0),
65 | egg.id.unwrap_or(0),
66 | }
67 | );
68 | }
69 | }
70 | Err(_) => exit(1),
71 | }
72 |
73 | Ok(())
74 | } else {
75 | help()
76 | }
77 | }
78 | Err(e) => Err(e),
79 | }
80 | }
81 |
82 | fn help() -> Result<()> {
83 | printth!(
84 | "{}",
85 | Help {
86 | command: "kurv stop",
87 | summary: Some(indoc! {
88 | "collects an egg and schedules it to be started.
89 |
90 | example:
91 | -> if we want to collect the egg ./egg.kurv :
92 |
93 | $ kurv collect ./egg.kurv ",
94 | }),
95 | error: None,
96 | options: Some(vec![
97 | ("-h, --help", vec![], "Prints this help message"),
98 | ("-j, --json", vec![], "Prints the response in json format")
99 | ]),
100 | subcommands: None
101 | }
102 | .render()
103 | );
104 |
105 | Ok(())
106 | }
107 |
--------------------------------------------------------------------------------
/src/cli/cmd/default.rs:
--------------------------------------------------------------------------------
1 | //! # Default command
2 | //! Command executed when no command was specified.
3 | //!
4 | //! This will execute general commands like `help` and `version`, depending
5 | //! on the arguments passed to the program.
6 |
7 | use indoc::indoc;
8 |
9 | use {
10 | crate::cli::components::{Component, Help},
11 | crate::printth,
12 | anyhow::Result,
13 | pico_args::Arguments,
14 | };
15 |
16 | pub fn run(args: &mut Arguments, err: Option<&str>) -> Result<()> {
17 | if args.contains(["-v", "--version"]) {
18 | print_version(args);
19 | return Ok(());
20 | }
21 |
22 | // by default, print help
23 | help(err);
24 | Ok(())
25 | }
26 |
27 | fn help(err: Option<&str>) {
28 | printth!(
29 | "{}",
30 | Help {
31 | command: "kurv",
32 | summary: Some(indoc!{
33 | "just a simple process manager =)
34 |
35 | ! you can also use kurv [command] --help to get
36 | help information about a specific command.
37 |
38 | » example
39 |
40 | $ kurv server --help "
41 | }),
42 | error: err,
43 | options: Some(vec![
44 | ("-h, --help", vec![], "prints help information"),
45 | ("-v, --version", vec![], "prints version information"),
46 | ]),
47 | subcommands: Some(vec![
48 | ("server", vec!["s"], "starts the kurv server"),
49 | ("list", vec!["l"], "prints eggs list and their statuses"),
50 | ("egg", vec![], "prints egg information"),
51 | ("stop", vec![], "stops a running egg"),
52 | ("start", vec![], "starts a stopped egg"),
53 | ("restart", vec![], "restarts a running egg"),
54 | ("remove", vec![], "removes an egg"),
55 | ("collect", vec![], "collects and starts a new egg"),
56 | ]),
57 | }
58 | .render()
59 | );
60 | }
61 |
62 | fn print_version(args: &mut Arguments) {
63 | let version = env!("CARGO_PKG_VERSION").to_string();
64 |
65 | if args.contains(["-j", "--json"]) {
66 | println!(
67 | "{}",
68 | serde_json::to_string_pretty(&serde_json::json!({
69 | "name": "kurv",
70 | "version": version
71 | }))
72 | .unwrap()
73 | );
74 | return;
75 | }
76 |
77 | printth!("kurv@ v{version} ");
78 |
79 | // TODO: in the future we could show local version and remote version
80 | }
81 |
--------------------------------------------------------------------------------
/src/cli/cmd/egg.rs:
--------------------------------------------------------------------------------
1 | use {
2 | crate::{
3 | cli::{
4 | cmd::{api::Api, is_option_or_flag, wants_help, wants_raw},
5 | components::{Component, Help},
6 | },
7 | common::str::ToString,
8 | kurv::{Egg, EggStatus},
9 | printth,
10 | },
11 | anyhow::{anyhow, Result},
12 | indoc::{formatdoc, indoc},
13 | pico_args::Arguments,
14 | std::path::PathBuf,
15 | };
16 |
17 | /// prints eggs state summary snapshot
18 | pub fn run(args: &mut Arguments) -> Result<()> {
19 | if wants_help(args) {
20 | return help();
21 | }
22 |
23 | let api = Api::new();
24 | let cmd_arg: Result > =
25 | args.opt_free_from_str().map_err(|_| anyhow!("wrong usage"));
26 |
27 | if let Ok(Some(id)) = cmd_arg {
28 | if is_option_or_flag(&id) {
29 | return Err(anyhow!("wrong usage"));
30 | }
31 |
32 | let response = api.egg(id.as_str());
33 |
34 | if let Ok(egg) = response {
35 | if wants_raw(args) {
36 | printth!("{}", serde_json::to_string_pretty(&egg)?);
37 | return Ok(());
38 | }
39 |
40 | let args = match egg.args.clone() {
41 | Some(args) => args.join(" "),
42 | None => "".to_string(),
43 | };
44 |
45 | printth!(
46 | "{}",
47 | formatdoc! {
48 | "
49 |
50 | ⬮ » {}
51 |
52 | id {}
53 | name {}
54 | command {}
55 | cwd {}
56 | ",
57 | egg.name,
58 | egg.id.unwrap(),
59 | egg.name,
60 | egg.command.clone() + " " + args.as_str() + " ",
61 | egg.cwd.clone().unwrap_or(PathBuf::from(".")).display(),
62 | }
63 | );
64 |
65 | print_env(&egg);
66 | println!();
67 | print_paths(&egg);
68 | println!();
69 | print_state(&egg);
70 | }
71 | } else {
72 | help()?;
73 | }
74 |
75 | Ok(())
76 | }
77 |
78 | fn print_state(egg: &Egg) {
79 | if let Some(state) = &egg.state {
80 | let status_color = match state.status {
81 | EggStatus::Pending => "dim",
82 | EggStatus::Running => "green",
83 | EggStatus::Stopped => "warn",
84 | EggStatus::Errored => "error",
85 | EggStatus::PendingRemoval => "warn",
86 | EggStatus::Restarting => "magenta",
87 | };
88 |
89 | let status = state.status.str().to_lowercase();
90 | let status = status.trim_end();
91 |
92 | printth!(
93 | "{}",
94 | formatdoc! {
95 | "
96 | status:
97 | status <{}>{}{}>
98 | pid {}
99 | start time {}
100 | try count {}
101 | error {}
102 | ",
103 | status_color,
104 | status,
105 | status_color,
106 | state.pid,
107 | state.start_time.unwrap_or_default(),
108 | state.try_count,
109 | state.error.clone().unwrap_or("".to_string()),
110 | }
111 | );
112 | }
113 | }
114 |
115 | fn print_env(egg: &Egg) {
116 | if let Some(env) = &egg.env {
117 | printth!("{}", "env: ");
118 |
119 | let max_key_len = env.keys().map(|k| k.len()).max().unwrap_or(0);
120 |
121 | for (key, value) in env {
122 | let padding = " ".repeat(max_key_len - key.len());
123 | printth!(" {} {} {}", key, padding, value);
124 | }
125 | }
126 | }
127 |
128 | fn print_paths(egg: &Egg) {
129 | if let Some(paths) = &egg.paths {
130 | printth!("{}", "paths: ");
131 |
132 | let maybe_stdout = paths.stdout.to_str();
133 | let maybe_stderr = paths.stderr.to_str();
134 |
135 | if let Some(stdout) = maybe_stdout {
136 | printth!(" stdout {}", stdout);
137 | }
138 |
139 | if let Some(stderr) = maybe_stderr {
140 | printth!(" stderr {}", stderr);
141 | }
142 | }
143 | }
144 |
145 | fn help() -> Result<()> {
146 | printth!(
147 | "{}",
148 | Help {
149 | command: "kurv egg",
150 | summary: Some(indoc! {
151 | "shows a snapshot of the current status of a specific egg
152 |
153 | example:
154 | $ kurv egg 1 # by id
155 | $ kurv egg myprocess # by name
156 | $ kurv egg 9778 # by pid "
157 | }),
158 | error: None,
159 | options: Some(vec![
160 | ("-h, --help", vec![], "Prints this help message"),
161 | ("-j, --json", vec![], "Prints the response in json format")
162 | ]),
163 | subcommands: None
164 | }
165 | .render()
166 | );
167 |
168 | Ok(())
169 | }
170 |
--------------------------------------------------------------------------------
/src/cli/cmd/list.rs:
--------------------------------------------------------------------------------
1 | use {
2 | crate::{
3 | cli::{
4 | cmd::{api::Api, wants_help, wants_raw},
5 | components::{Component, Help},
6 | },
7 | common::str::ToString,
8 | kurv::EggStatus,
9 | printth,
10 | },
11 | anyhow::Result,
12 | cli_table::{
13 | format::{
14 | Border, BorderBuilder, HorizontalLine, Justify, Separator, SeparatorBuilder,
15 | VerticalLine,
16 | },
17 | print_stdout, Cell, CellStruct, Color, Style, Table,
18 | },
19 | indoc::indoc,
20 | pico_args::Arguments,
21 | };
22 |
23 | /// prints eggs state summary snapshot
24 | pub fn run(args: &mut Arguments) -> Result<()> {
25 | if wants_help(args) {
26 | return help();
27 | }
28 |
29 | let (border, separator) = get_borders();
30 | let api = Api::new();
31 | let eggs_summary_list = api.eggs_summary()?;
32 |
33 | // if wants raw json output
34 | if wants_raw(args) {
35 | if eggs_summary_list.0.is_empty() {
36 | printth!("{}", "[]");
37 | return Ok(());
38 | }
39 |
40 | printth!("{}", serde_json::to_string_pretty(&eggs_summary_list)?);
41 | return Ok(());
42 | }
43 |
44 | if eggs_summary_list.0.is_empty() {
45 | printth!(indoc! {
46 | "\nthere are no ⬮ 's in the kurv =(
47 |
48 | i collect some eggs to get started:
49 | $ kurv collect my-egg.kurv
50 | "
51 | });
52 | return Ok(());
53 | }
54 |
55 | printth!("\n⬮ eggs snapshot \n");
56 |
57 | let rows: Vec> = eggs_summary_list
58 | .0
59 | .iter()
60 | .map(|egg| {
61 | vec![
62 | egg.id.cell().bold(true).foreground_color(Some(Color::Blue)),
63 | egg.pid.cell(),
64 | egg.name.clone().cell(),
65 | egg.status
66 | .str()
67 | .to_lowercase()
68 | .cell()
69 | .bold(true)
70 | .foreground_color(color_by_status(egg.status))
71 | .dimmed(dim_by_status(egg.status)),
72 | egg.retry_count.cell().justify(Justify::Center),
73 | egg.uptime.clone().cell().justify(Justify::Center),
74 | ]
75 | })
76 | .collect();
77 |
78 | let table = rows
79 | .table()
80 | .dimmed(true)
81 | .title(vec![
82 | "#".cell().bold(true).foreground_color(Some(Color::Blue)),
83 | "pid".cell().bold(true).foreground_color(Some(Color::Blue)),
84 | "name".cell().bold(true).foreground_color(Some(Color::Blue)),
85 | "status"
86 | .cell()
87 | .bold(true)
88 | .foreground_color(Some(Color::Blue)),
89 | "↺"
90 | .cell()
91 | .bold(true)
92 | .foreground_color(Some(Color::Blue))
93 | .justify(Justify::Center),
94 | "uptime"
95 | .cell()
96 | .bold(true)
97 | .foreground_color(Some(Color::Blue))
98 | .justify(Justify::Center),
99 | ])
100 | .border(border.build())
101 | .separator(separator.build());
102 |
103 | print_stdout(table)?;
104 | println!();
105 |
106 | Ok(())
107 | }
108 |
109 | fn help() -> Result<()> {
110 | printth!(
111 | "{}",
112 | Help {
113 | command: "kurv list",
114 | summary: Some(indoc! {
115 | "shows a snapshot table with a list of all collected
116 | eggs and their current statuses."
117 | }),
118 | error: None,
119 | options: Some(vec![
120 | ("-h, --help", vec![], "Prints this help message"),
121 | ("-j, --json", vec![], "Prints the response in json format")
122 | ]),
123 | subcommands: None
124 | }
125 | .render()
126 | );
127 |
128 | Ok(())
129 | }
130 |
131 | fn color_by_status(status: EggStatus) -> Option {
132 | match status {
133 | EggStatus::Running => Some(Color::Green),
134 | EggStatus::Errored => Some(Color::Red),
135 | EggStatus::Stopped => Some(Color::Yellow),
136 | EggStatus::Pending => Some(Color::Blue),
137 | EggStatus::PendingRemoval => Some(Color::Red),
138 | EggStatus::Restarting => Some(Color::Magenta),
139 | }
140 | }
141 |
142 | fn dim_by_status(status: EggStatus) -> bool {
143 | match status {
144 | EggStatus::PendingRemoval => true,
145 | EggStatus::Restarting => true,
146 | EggStatus::Pending => true,
147 | EggStatus::Running => false,
148 | EggStatus::Errored => false,
149 | EggStatus::Stopped => false,
150 | }
151 | }
152 |
153 | fn get_borders() -> (BorderBuilder, SeparatorBuilder) {
154 | let border = Border::builder()
155 | .bottom(HorizontalLine::new('╰', '╯', '┴', '─'))
156 | .top(HorizontalLine::new('╭', '╮', '┬', '─'))
157 | .left(VerticalLine::new('│'))
158 | .right(VerticalLine::new('│'));
159 |
160 | let separator = Separator::builder()
161 | .column(Some(VerticalLine::new('│')))
162 | .row(None)
163 | .title(Some(HorizontalLine::new('├', '┤', '┼', '─')));
164 |
165 | (border, separator)
166 | }
167 |
--------------------------------------------------------------------------------
/src/cli/cmd/mod.rs:
--------------------------------------------------------------------------------
1 | use pico_args::Arguments;
2 |
3 | mod api;
4 | pub mod collect;
5 | pub mod default;
6 | pub mod egg;
7 | pub mod list;
8 | pub mod server_help;
9 | pub mod stop_start;
10 |
11 | /// Returns true if the user wants help with the command
12 | pub fn wants_help(args: &mut Arguments) -> bool {
13 | args.contains(["-h", "--help"])
14 | }
15 |
16 | /// Returns true if the user wants raw json output
17 | pub fn wants_raw(args: &mut Arguments) -> bool {
18 | args.contains(["-j", "--json"])
19 | }
20 |
21 | /// checks if an argument is not an option or a flag (starts with - or --)
22 | pub fn is_option_or_flag(arg: &str) -> bool {
23 | arg.starts_with('-')
24 | }
25 |
--------------------------------------------------------------------------------
/src/cli/cmd/server_help.rs:
--------------------------------------------------------------------------------
1 | //! # Default command
2 | //! Command executed when no command was specified.
3 | //!
4 | //! This will execute general commands like `help` and `version`, depending
5 | //! on the arguments passed to the program.
6 |
7 | use indoc::indoc;
8 |
9 | use {
10 | crate::cli::components::{Component, Help},
11 | crate::printth,
12 | };
13 |
14 | pub fn print() {
15 | printth!(
16 | "{}",
17 | Help {
18 | command: "kurv server",
19 | summary: Some(indoc! {"
20 | starts the kurv server and all its dependant eggs
21 |
22 | ! this command will only work if the environment
23 | variable KURV_SERVER is setted to `true`"
24 | }),
25 | error: None,
26 | options: Some(vec![(
27 | "--force",
28 | vec![],
29 | "bypass the KURV_SERVER env var check"
30 | )]),
31 | subcommands: None,
32 | }
33 | .render()
34 | );
35 | }
36 |
--------------------------------------------------------------------------------
/src/cli/cmd/stop_start.rs:
--------------------------------------------------------------------------------
1 | use anyhow::anyhow;
2 | use indoc::formatdoc;
3 |
4 | use crate::cli::cmd::wants_raw;
5 |
6 | use {
7 | crate::cli::{
8 | cmd::{api::Api, is_option_or_flag, wants_help},
9 | components::{Component, Help},
10 | },
11 | crate::printth,
12 | anyhow::Result,
13 | indoc::indoc,
14 | pico_args::Arguments,
15 | };
16 |
17 | /// indicates wether we want to stop or start an egg
18 | pub enum StopStartAction {
19 | Stop,
20 | Start,
21 | Remove,
22 | Restart,
23 | }
24 |
25 | struct Strings<'a> {
26 | action: &'a str,
27 | doing_action: &'a str,
28 | past_action: &'a str,
29 | }
30 |
31 | /// stops a runnig egg
32 | ///
33 | /// IDEA: it works asynchronously, this means that wehen the command
34 | /// ends, the egg might still be running. We could implement a --timeout X
35 | /// option that will check the actual status of the egg until it IS actually
36 | /// Stopped (has no pid), or reaches timeouts (in which case it should end
37 | /// with an error exit code)
38 | pub fn run(args: &mut Arguments, action: StopStartAction) -> Result<()> {
39 | let strings = get_strings(action);
40 |
41 | if wants_help(args) {
42 | return help(strings);
43 | }
44 |
45 | let api = Api::new();
46 | let cmd_arg: Result> =
47 | args.opt_free_from_str().map_err(|_| anyhow!("wrong usage"));
48 |
49 | match cmd_arg {
50 | Ok(maybe_arg) => match maybe_arg {
51 | Some(id) => {
52 | if is_option_or_flag(&id) {
53 | return Err(anyhow!("wrong usage"));
54 | }
55 |
56 | let json_resp = wants_raw(args);
57 |
58 | if !json_resp {
59 | printth!(
60 | "\n⬮ {} egg {} \n",
61 | strings.doing_action,
62 | id
63 | );
64 | }
65 |
66 | let response = api.eggs_post(format!("/{}/{}", id, strings.action).as_str(), "");
67 |
68 | if let Ok(egg) = response {
69 | if json_resp {
70 | printth!("{}", serde_json::to_string_pretty(&egg)?);
71 | return Ok(());
72 | }
73 |
74 | printth!(
75 | indoc! {
76 | "egg {} has been scheduled to be {}
77 |
78 | i you can check its status by running:
79 | $ kurv egg 1
80 | "
81 | },
82 | egg.name,
83 | strings.past_action
84 | );
85 | }
86 |
87 | Ok(())
88 | }
89 | None => help(strings),
90 | },
91 | Err(e) => Err(e),
92 | }
93 | }
94 |
95 | fn help(strings: Strings) -> Result<()> {
96 | printth!(
97 | "{}",
98 | Help {
99 | command: format!("kurv {}", strings.action).as_ref(),
100 | summary: Some(formatdoc! {
101 | "schedules an egg to be {} by the kurv server
102 |
103 | example:
104 | $ kurv {} 1 # by id
105 | $ kurv {} myprocess # by name
106 | $ kurv {} 9778 # by pid ",
107 | strings.past_action,
108 | strings.action,
109 | strings.action,
110 | strings.action,
111 | }.as_ref()),
112 | error: None,
113 | options: Some(vec![
114 | ("-h, --help", vec![], "Prints this help message"),
115 | ("-j, --json", vec![], "Prints the response in json format")
116 | ]),
117 | subcommands: None
118 | }
119 | .render()
120 | );
121 |
122 | Ok(())
123 | }
124 |
125 | fn get_strings<'a>(action: StopStartAction) -> Strings<'a> {
126 | match action {
127 | StopStartAction::Start => Strings {
128 | action: "start",
129 | doing_action: "starting",
130 | past_action: "started",
131 | },
132 | StopStartAction::Stop => Strings {
133 | action: "stop",
134 | doing_action: "stopping",
135 | past_action: "stopped",
136 | },
137 | StopStartAction::Remove => Strings {
138 | action: "remove",
139 | doing_action: "removing",
140 | past_action: "removed",
141 | },
142 | StopStartAction::Restart => Strings {
143 | action: "restart",
144 | doing_action: "restarting",
145 | past_action: "restarted",
146 | },
147 | }
148 | }
149 |
--------------------------------------------------------------------------------
/src/cli/color/mod.rs:
--------------------------------------------------------------------------------
1 | mod style;
2 | pub mod theme;
3 |
4 | pub use theme::Theme;
5 |
6 | use {
7 | self::style::StyleItem,
8 | crossterm::style::{Attribute, Color},
9 | velcro::hash_map_from,
10 | };
11 |
12 | /// 🎨 » returns the theme of the application
13 | pub fn get_theme() -> Theme {
14 | Theme::new(hash_map_from! {
15 | "head": [
16 | StyleItem::Color(Color::DarkBlue),
17 | StyleItem::Attr(Attribute::Bold),
18 | ],
19 | "highlight": [
20 | StyleItem::Color(Color::White),
21 | StyleItem::Attr(Attribute::Bold),
22 | ],
23 | "dim": [
24 | StyleItem::Attr(Attribute::Dim),
25 | ],
26 | "magenta": [
27 | StyleItem::Color(Color::Magenta),
28 | ],
29 | "white": [
30 | StyleItem::Color(Color::White),
31 | ],
32 | "green": [
33 | StyleItem::Color(Color::Green),
34 | ],
35 | "blue": [
36 | StyleItem::Color(Color::DarkBlue),
37 | ],
38 | "yellow": [
39 | StyleItem::Color(Color::Yellow),
40 | ],
41 | "error": [
42 | StyleItem::Color(Color::Red),
43 | StyleItem::Attr(Attribute::Bold),
44 | ],
45 | "b": [
46 | StyleItem::Attr(Attribute::Bold),
47 | ],
48 | "error": [
49 | StyleItem::Color(Color::Red),
50 | ],
51 | "warn": [
52 | StyleItem::Color(Color::Yellow),
53 | ],
54 | "info": [
55 | StyleItem::Color(Color::White),
56 | ],
57 | "debug": [
58 | StyleItem::Color(Color::Magenta),
59 | ],
60 | "trace": [
61 | StyleItem::Color(Color::Cyan),
62 | ],
63 | })
64 | }
65 |
--------------------------------------------------------------------------------
/src/cli/color/style.rs:
--------------------------------------------------------------------------------
1 | use crossterm::style::{style, Attribute, Color, Stylize};
2 |
3 | /// converts a hex color to a crossterm `Color::Rgb`
4 | fn hex_to_rgb(hex: &str) -> Result {
5 | let hex = hex.trim_start_matches('#');
6 | let r = u8::from_str_radix(&hex[0..2], 16)?;
7 | let g = u8::from_str_radix(&hex[2..4], 16)?;
8 | let b = u8::from_str_radix(&hex[4..6], 16)?;
9 |
10 | Ok(Color::Rgb { r, g, b })
11 | }
12 |
13 | /// a single style item
14 | #[derive(Clone)]
15 | #[allow(dead_code)]
16 | pub enum StyleItem {
17 | Attr(Attribute),
18 | Rgb(&'static str),
19 | Color(Color),
20 | }
21 |
22 | /// a collection of style items
23 | #[derive(Clone)]
24 | pub struct Style(pub Vec);
25 |
26 | impl Style {
27 | /// merges two styles into one
28 | pub fn merge(&self, other: &Style) -> Style {
29 | let merged_items: Vec = self
30 | .0
31 | .iter()
32 | .cloned()
33 | .chain(other.0.iter().cloned())
34 | .collect();
35 | Style(merged_items)
36 | }
37 |
38 | /// applies the style to the given text
39 | pub fn apply_to(&self, text: &str) -> String {
40 | let mut styled_text = text.to_owned();
41 |
42 | for style_item in &self.0 {
43 | match style_item {
44 | StyleItem::Attr(attribute) => {
45 | styled_text = style(styled_text).attribute(*attribute).to_string();
46 | }
47 | StyleItem::Rgb(hex_color) => {
48 | if let Ok(rgb_color) = hex_to_rgb(hex_color) {
49 | styled_text = style(styled_text).with(rgb_color).to_string();
50 | }
51 | }
52 | StyleItem::Color(color) => {
53 | styled_text = style(styled_text).with(*color).to_string();
54 | }
55 | }
56 | }
57 |
58 | styled_text
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/cli/color/theme.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::get_theme,
3 | super::style::{Style, StyleItem},
4 | htmlparser::{Token, Tokenizer},
5 | std::{collections::HashMap, sync::Once},
6 | };
7 |
8 | /// the wrapper element used to wrap the input string before parsing
9 | const WRAPPER_ELEMENT: &str = "_wrapper_";
10 |
11 | /// Represents the theme of the application.
12 | /// A theme is a collection of styles, each style is associated with a tag.
13 | /// For example, the tag `brand` might be associated with a style that has
14 | /// a specific color and bold attribute.
15 | ///
16 | /// To apply a theme to a string, we need to format such string as something
17 | /// similar to HTML. For example, the string `This is a red text` might be
18 | /// formatted as `This is a red text `.
19 | ///
20 | /// We can also thin of theme tags as styling components.
21 | pub struct Theme(pub HashMap);
22 |
23 | #[derive(Clone, Debug)]
24 | pub enum ParsedNode {
25 | Text(String),
26 | Tag(String, Vec),
27 | }
28 |
29 | impl Theme {
30 | /// creates a new theme from a hashmap
31 | pub fn new(map: HashMap>) -> Theme {
32 | let mut theme = Theme(HashMap::new());
33 |
34 | for (key, value) in map {
35 | theme.insert(&key, Style(value));
36 | }
37 |
38 | theme
39 | }
40 |
41 | /// inserts a new style into the theme
42 | pub fn insert(&mut self, key: &str, style: Style) {
43 | self.0.insert(String::from(key), style);
44 | }
45 |
46 | /// applies the theme to the given node
47 | fn apply_node(&self, node: &ParsedNode, parent_style: &Style) -> String {
48 | let (node_style, child_style): (Style, Vec) = match node {
49 | ParsedNode::Text(_) => (Style(vec![]), vec![]),
50 | ParsedNode::Tag(tag, children) => {
51 | let node_style = self.0.get(tag).cloned().unwrap_or_else(|| {
52 | // Default style if the tag is not found in the theme
53 | Style(vec![])
54 | });
55 |
56 | let child_style: Vec = children
57 | .iter()
58 | .map(|child| self.apply_node(child, &node_style))
59 | .collect();
60 |
61 | (node_style, child_style)
62 | }
63 | };
64 |
65 | let combined_style = parent_style.merge(&node_style);
66 |
67 | match node {
68 | ParsedNode::Text(text) => combined_style.apply_to(text),
69 | ParsedNode::Tag(_, _) => {
70 | let styled_children: String = child_style.join("");
71 | combined_style.apply_to(&styled_children)
72 | }
73 | }
74 | }
75 |
76 | pub fn apply(&self, text: &str) -> String {
77 | let nodes = parse(text);
78 |
79 | nodes
80 | .iter()
81 | .map(|node| self.apply_node(node, &Style(vec![])))
82 | .collect::()
83 | }
84 | }
85 |
86 | /// parses a string into a collection of nodes
87 | pub fn parse(string: &str) -> Vec {
88 | // wrap all the string in a wrapper element
89 | let string = &format!("<{}>{}{}>", WRAPPER_ELEMENT, string, WRAPPER_ELEMENT);
90 |
91 | let mut nodes = vec![];
92 | let tokens = Tokenizer::from(&string[..]);
93 | let mut stack = vec![];
94 |
95 | for token in tokens {
96 | let token = match token {
97 | Ok(token) => token,
98 | Err(_) => continue,
99 | };
100 |
101 | match token {
102 | Token::ElementStart { prefix, local, .. } => {
103 | stack.push((prefix, local, vec![]));
104 | }
105 | Token::ElementEnd { end: htmlparser::ElementEnd::Close(_, local), .. } => {
106 | let (_, _, content) = stack.pop().unwrap();
107 | let parsed_node = ParsedNode::Tag(local.to_string(), content);
108 | if let Some(top) = stack.last_mut() {
109 | top.2.push(parsed_node);
110 | } else {
111 | nodes.push(parsed_node);
112 | }
113 | }
114 | Token::Text { text } => {
115 | if let Some(top) = stack.last_mut() {
116 | top.2.push(ParsedNode::Text(text.to_string()));
117 | } else {
118 | nodes.push(ParsedNode::Text(text.to_string()));
119 | }
120 | }
121 | _ => {}
122 | }
123 | }
124 |
125 | // filter out the wrapper element
126 | nodes
127 | .into_iter()
128 | .flat_map(|node| match node {
129 | ParsedNode::Tag(tag, content) if tag == WRAPPER_ELEMENT => {
130 | content.clone()
131 | }
132 | _ => vec![node],
133 | })
134 | .collect::>()
135 | }
136 |
137 | pub static INIT: Once = Once::new();
138 | pub static mut GLOBAL_THEME: Option = None;
139 |
140 | pub fn initialize_theme() {
141 | INIT.call_once(|| unsafe {
142 | GLOBAL_THEME = Some(get_theme());
143 | });
144 | }
145 |
146 | /// prints a string by using the global theme
147 | #[macro_export]
148 | macro_rules! printth {
149 | // This pattern captures the arguments passed to the macro.
150 | ($($arg:tt)*) => {
151 | $crate::cli::color::theme::initialize_theme();
152 | unsafe {
153 | let theme_ptr = std::ptr::addr_of!($crate::cli::color::theme::GLOBAL_THEME);
154 | if let Some(theme) = (*theme_ptr).as_ref() {
155 | let formatted_string = format!($($arg)*);
156 | let themed_string = theme.apply(&formatted_string);
157 | println!("{}", themed_string);
158 | }
159 | }
160 | };
161 | }
--------------------------------------------------------------------------------
/src/cli/components/help.rs:
--------------------------------------------------------------------------------
1 | use super::{Component, Logo};
2 | use std::fmt::Write;
3 |
4 | static SUMMARY: &str = "{summary}";
5 | static OPTIONS_HEAD: &str = "options";
6 | static SUBCOMMANDS_HEAD: &str = "commands";
7 |
8 | type Item<'a> = &'a str;
9 | type Aliases<'a> = Vec<&'a str>;
10 | type Desc<'a> = &'a str;
11 | type AliasedItem<'a> = Vec<(Item<'a>, Aliases<'a>, Desc<'a>)>;
12 |
13 | pub struct Help<'a> {
14 | pub command: &'a str,
15 | pub error: Option<&'a str>,
16 | pub summary: Option<&'a str>,
17 | pub options: Option>,
18 | pub subcommands: Option>,
19 | }
20 |
21 | impl<'a> Component for Help<'a> {
22 | fn render(&self) -> String {
23 | let logo = Logo {};
24 |
25 | let mut help = String::new();
26 |
27 | help.push_str(&logo.render());
28 |
29 | if let Some(error) = &self.error {
30 | help.push_str(&format!("\n😱 {error} \n"));
31 | }
32 |
33 | // Modify the usage string based on the presence of options and subcommands
34 | let mut usage = String::from("\nusage\n {command} ");
35 | if self.options.is_some() {
36 | usage.push_str(" [options] ");
37 | }
38 | if self.subcommands.is_some() {
39 | usage.push_str(" [command] ");
40 | }
41 |
42 | usage.push_str(" [args...] ");
43 |
44 | help.push_str(&usage.replace("{command}", self.command));
45 |
46 | if let Some(summary) = &self.summary {
47 | help.push_str(&format!("\n\n{}", SUMMARY.replace("{summary}", summary)));
48 | }
49 |
50 | if let Some(options) = &self.options {
51 | help.push_str("\n\n");
52 | help.push_str(OPTIONS_HEAD);
53 | help.push_str(&self.render_items(options, ", "));
54 | }
55 |
56 | if let Some(subcommands) = &self.subcommands {
57 | help.push_str("\n\n");
58 | help.push_str(SUBCOMMANDS_HEAD);
59 | help.push_str(&self.render_items(subcommands, "|"));
60 | }
61 |
62 | help.push('\n');
63 |
64 | help
65 | }
66 | }
67 |
68 | impl<'a> Help<'a> {
69 | fn render_items(&self, items: &AliasedItem<'a>, separator: &str) -> String {
70 | // Calculate the gutter space dynamically based on the length of the longest item
71 | let gutter_space = items
72 | .iter()
73 | .map(|(item, _, _)| item.len())
74 | .max()
75 | .unwrap_or(0)
76 | + 4;
77 |
78 | let mut result = String::new();
79 |
80 | for (item, aliases, description) in items {
81 | let item = match aliases.len() {
82 | 0 => item.to_string(),
83 | _ => format!("{}{separator}{}", item, aliases.join(separator)),
84 | };
85 |
86 | write!(
87 | &mut result,
88 | "\n {:{}",
89 | item,
90 | description,
91 | width = gutter_space
92 | )
93 | .unwrap();
94 | }
95 |
96 | result
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/src/cli/components/logo.rs:
--------------------------------------------------------------------------------
1 | use super::Component;
2 |
3 | const LOGO_STR: &str = r#"
4 | ▀▀████
5 | ████
6 | ████ ▀█▀ ▀████ ▀███ ▀███ ▄██▄ ▀███▀ ▀▀█▀
7 | ████ ▄▀ ████ ███ ███▀ ██▀ ▀██▄ ▞
8 | ████▅██▃ ████ ███ ███ ███▄ ▞
9 | ████ ▀██▆ ████ ▄███ ███ ███ ▞
10 | ▄▄████▄ ▄███▄ ▀██▅▀ ███▄ ▄███▄▄ ███
11 | "#;
12 |
13 | pub struct Logo {}
14 |
15 | impl Component for Logo {
16 | fn render(&self) -> String {
17 | LOGO_STR.to_string()
18 | }
19 | }
20 |
--------------------------------------------------------------------------------
/src/cli/components/mod.rs:
--------------------------------------------------------------------------------
1 | mod help;
2 | mod logo;
3 |
4 | pub use help::Help;
5 | pub use logo::Logo;
6 |
7 | pub trait Component {
8 | /// render method
9 | fn render(&self) -> String;
10 | }
11 |
--------------------------------------------------------------------------------
/src/cli/mod.rs:
--------------------------------------------------------------------------------
1 | use {
2 | self::cmd::{stop_start::StopStartAction, wants_help},
3 | anyhow::{anyhow, Result},
4 | pico_args::Arguments,
5 | };
6 |
7 | pub mod cmd;
8 | pub mod color;
9 | pub mod components;
10 |
11 | pub enum DispatchResult {
12 | Dispatched,
13 | Server,
14 | }
15 |
16 | pub fn dispatch_command() -> Result {
17 | let mut arguments = Arguments::from_env();
18 | let subcommand = arguments.subcommand()?;
19 |
20 | let result = match subcommand {
21 | Some(subcmd) => {
22 | match subcmd.as_ref() {
23 | "server" | "s" => {
24 | if wants_help(&mut arguments) {
25 | cmd::server_help::print();
26 | return Ok(DispatchResult::Dispatched);
27 | }
28 |
29 | // server will be handled by the main function
30 | Ok(DispatchResult::Server)
31 | }
32 | "list" | "l" | "ls" | "snaps" => {
33 | cmd::list::run(&mut arguments).map(|_| DispatchResult::Dispatched)
34 | }
35 | "egg" => cmd::egg::run(&mut arguments).map(|_| DispatchResult::Dispatched),
36 | "stop" => cmd::stop_start::run(&mut arguments, StopStartAction::Stop)
37 | .map(|_| DispatchResult::Dispatched),
38 | "start" => cmd::stop_start::run(&mut arguments, StopStartAction::Start)
39 | .map(|_| DispatchResult::Dispatched),
40 | "remove" => cmd::stop_start::run(&mut arguments, StopStartAction::Remove)
41 | .map(|_| DispatchResult::Dispatched),
42 | "restart" => cmd::stop_start::run(&mut arguments, StopStartAction::Restart)
43 | .map(|_| DispatchResult::Dispatched),
44 | "collect" => cmd::collect::run(&mut arguments).map(|_| DispatchResult::Dispatched),
45 | _ => cmd::default::run(
46 | &mut arguments,
47 | Some(format!("Invalid usage | Command '{}' not recognized", subcmd).as_str()),
48 | )
49 | .map(|_| DispatchResult::Dispatched),
50 | }
51 | }
52 | // if there is no subcommand, run the default command
53 | None => cmd::default::run(&mut arguments, None).map(|_| DispatchResult::Dispatched),
54 | };
55 |
56 | result.map_err(|err| anyhow!(err))
57 | }
58 |
--------------------------------------------------------------------------------
/src/common/duration.rs:
--------------------------------------------------------------------------------
1 | use chrono::Duration;
2 |
3 | pub fn humanize_duration(duration: Duration) -> String {
4 | if duration.num_days() >= 30 {
5 | let months = duration.num_days() / 30;
6 | return format!("{} month{}", months, if months > 1 { "s" } else { "" });
7 | }
8 |
9 | if duration.num_days() > 0 {
10 | let days = duration.num_days();
11 | return format!("{}d", days);
12 | }
13 |
14 | if duration.num_hours() > 0 {
15 | let hours = duration.num_hours();
16 | return format!("{}h", hours);
17 | }
18 |
19 | if duration.num_minutes() > 0 {
20 | let minutes = duration.num_minutes();
21 | return format!("{}m", minutes);
22 | }
23 |
24 | if duration.num_seconds() > 0 {
25 | let seconds = duration.num_seconds();
26 | return format!("{}s", seconds);
27 | }
28 |
29 | "< 1 second".to_string()
30 | }
31 |
--------------------------------------------------------------------------------
/src/common/info.rs:
--------------------------------------------------------------------------------
1 | use {
2 | anyhow::Result,
3 | env::{current_dir, current_exe},
4 | serde::{Deserialize, Serialize},
5 | std::{env, path::PathBuf},
6 | };
7 |
8 | const KURV_HOME_KEY: &str = "KURV_HOME";
9 |
10 | /// Important paths for the application
11 | #[derive(PartialEq, Eq, Clone, Deserialize, Serialize)]
12 | pub struct Paths {
13 | /// the path of the executable
14 | pub executable: PathBuf,
15 |
16 | /// the working directory of the application, might be different
17 | /// from the executable path
18 | pub working_dir: PathBuf,
19 |
20 | /// the path to the kurv home directory; it will be the parent dir of the executable
21 | /// or the value of the KURV_HOME environment variable if it is set
22 | pub kurv_home: PathBuf,
23 |
24 | /// the path to the .kurv file in the kurv home directory
25 | pub kurv_file: PathBuf,
26 | }
27 |
28 | /// General information about the app
29 | #[derive(PartialEq, Eq, Clone, Deserialize, Serialize)]
30 | pub struct Info {
31 | /// the version of the application
32 | pub name: String,
33 |
34 | /// the version of the application
35 | pub version: String,
36 |
37 | /// the version compilation name
38 | #[serde(skip_serializing_if = "Option::is_none")]
39 | pub version_name: Option<&'static str>,
40 |
41 | /// description
42 | pub description: String,
43 |
44 | /// the process id of the application
45 | pub pid: u32,
46 |
47 | /// important paths for the application
48 | pub paths: Paths,
49 | }
50 |
51 | impl Info {
52 | /// Creates a new instance of Info
53 | pub fn new() -> Info {
54 | Info {
55 | name: env!("CARGO_PKG_NAME").to_string(),
56 | version: env!("CARGO_PKG_VERSION").to_string(),
57 | version_name: option_env!("KURV_VERSION_NAME"),
58 | description: env!("CARGO_PKG_DESCRIPTION").to_string(),
59 | pid: std::process::id(),
60 | paths: Info::get_paths().expect("could not get paths"),
61 | }
62 | }
63 |
64 | /// Gets the paths for the application
65 | fn get_paths() -> Result {
66 | let executable = current_exe().expect("could not get executable path");
67 | let working_dir = current_dir().expect("could not get working directory");
68 |
69 | // kurv home is the parent dir of the executable or the
70 | // value of the KURV_HOME environment variable if it is set
71 | let kurv_home = match env::var(KURV_HOME_KEY) {
72 | Ok(home) => PathBuf::from(home),
73 | Err(_) => executable.parent().unwrap().to_path_buf(),
74 | };
75 |
76 | // the path to the .kurv file in the kurv
77 | // home directory it might not exist yet
78 | let kurv_file = kurv_home.join(".kurv");
79 |
80 | Ok(Paths {
81 | executable,
82 | working_dir,
83 | kurv_home,
84 | kurv_file,
85 | })
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/src/common/log.rs:
--------------------------------------------------------------------------------
1 | use anyhow::{anyhow, Result};
2 | use log::{Level, Metadata, Record};
3 |
4 | use crate::printth;
5 |
6 | pub struct Logger;
7 |
8 | impl Logger {
9 | pub fn init(max_level: Level) -> Result<()> {
10 | log::set_logger(&Logger)
11 | .map(|()| log::set_max_level(max_level.to_level_filter()))
12 | .map_err(|err| anyhow!("failed to set logger: {}", err))
13 | }
14 |
15 | fn level_theme(&self, level: Level) -> &str {
16 | match level {
17 | Level::Error => "error",
18 | Level::Warn => "warn",
19 | Level::Info => "info",
20 | Level::Debug => "debug",
21 | Level::Trace => "trace",
22 | }
23 | }
24 | }
25 |
26 | /// simple implementation of a themed logger
27 | impl log::Log for Logger {
28 | fn enabled(&self, metadata: &Metadata) -> bool {
29 | metadata.level() <= Level::Info
30 | }
31 |
32 | fn log(&self, record: &Record) {
33 | let thm = self.level_theme(record.level());
34 | let date = chrono::Local::now().format("%Y-%m-%d %H:%M:%S");
35 |
36 | match record.level() {
37 | Level::Info => {
38 | printth!("{} <{thm}>»{thm}> {}", date, record.args());
39 | }
40 | _ => {
41 | printth!("{} <{thm}>» {}{thm}>", date, record.args());
42 | }
43 | }
44 | }
45 |
46 | fn flush(&self) {}
47 | }
48 |
--------------------------------------------------------------------------------
/src/common/mod.rs:
--------------------------------------------------------------------------------
1 | mod info;
2 |
3 | pub mod duration;
4 | pub mod log;
5 | pub mod str;
6 | pub mod tcp;
7 |
8 | pub use info::Info;
9 |
--------------------------------------------------------------------------------
/src/common/str.rs:
--------------------------------------------------------------------------------
1 | use {serde::Serialize, serde_yaml::to_string};
2 |
3 | pub fn str(t: &T) -> String
4 | where
5 | T: Serialize,
6 | {
7 | match to_string(t) {
8 | Ok(s) => s,
9 |
10 | // if we couldn't serialize to string with serde
11 | // we return te struct name
12 | Err(_) => std::any::type_name::().to_string(),
13 | }
14 | }
15 |
16 | pub trait ToString {
17 | fn str(&self) -> String;
18 | }
19 |
20 | impl ToString for T
21 | where
22 | T: Serialize,
23 | {
24 | fn str(&self) -> String {
25 | str(self)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/common/tcp/mod.rs:
--------------------------------------------------------------------------------
1 | use {
2 | log::trace,
3 | serde::{Deserialize, Serialize},
4 | serde_yaml,
5 | std::{
6 | collections::HashMap,
7 | fmt::Display,
8 | io::{prelude::BufRead, BufReader, Read, Write},
9 | net::TcpStream,
10 | },
11 | };
12 |
13 | /// List of common HTTP methods mapped to their string representations.
14 | const RESPONSE_CODES: [(u16, &str); 14] = [
15 | (200, "OK"),
16 | (201, "Created"),
17 | (202, "Accepted"),
18 | (204, "No Content"),
19 | (400, "Bad Request"),
20 | (401, "Unauthorized"),
21 | (403, "Forbidden"),
22 | (404, "Not Found"),
23 | (405, "Method Not Allowed"),
24 | (409, "Conflict"),
25 | (418, "I'm a teapot"),
26 | (500, "Internal Server Error"),
27 | (501, "Not Implemented"),
28 | (505, "HTTP Version Not Supported"),
29 | ];
30 |
31 | /// Returns the string representation of the given status code.
32 | fn get_status_text(status: u16) -> String {
33 | RESPONSE_CODES
34 | .iter()
35 | .find(|&&(code, _)| code == status)
36 | .map(|&(_, text)| text)
37 | .unwrap_or("Unknown Status")
38 | .to_string()
39 | }
40 |
41 | /// A Request is a struct that holds the request data
42 | /// and is passed to the handler function.
43 | #[derive(Serialize, Debug, Clone)]
44 | pub struct Request {
45 | pub method: String,
46 | pub path: String,
47 | pub version: String,
48 | pub headers: Vec,
49 | pub body: String,
50 | pub query_params: HashMap,
51 | pub path_params: HashMap,
52 | }
53 |
54 | impl Display for Request {
55 | /// format as yaml
56 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
57 | let yaml = serde_yaml::to_string(&self).unwrap();
58 | write!(f, "{}", yaml)
59 | }
60 | }
61 |
62 | /// A Response is a struct that holds the response data
63 | /// and is returned from the handler function.
64 | pub struct Response {
65 | pub status: u16,
66 | pub headers: Vec,
67 | pub body: Vec,
68 | }
69 |
70 | /// common error response
71 | #[derive(Serialize, Deserialize)]
72 | pub struct ErrorResponse {
73 | pub code: u16,
74 | pub status: String,
75 | pub message: String,
76 | }
77 |
78 | pub trait Handler {
79 | fn handle(&self, request: &mut Request) -> Response;
80 | }
81 |
82 | /// Returns a JSON response with the given body and status code.
83 | pub fn json(status: u16, body: T) -> Response {
84 | let body = serde_json::to_vec(&body).unwrap();
85 |
86 | Response {
87 | status,
88 | headers: vec!["Content-Type: application/json".to_string()],
89 | body,
90 | }
91 | }
92 |
93 | pub fn err(status: u16, msg: String) -> Response {
94 | json(
95 | status,
96 | ErrorResponse {
97 | code: status,
98 | status: get_status_text(status),
99 | message: msg,
100 | },
101 | )
102 | }
103 |
104 | /// Handles an incoming TCP connection stream.
105 | pub fn handle(mut stream: TcpStream, handler: &impl Handler) {
106 | let mut buf_reader = BufReader::new(&mut stream);
107 |
108 | // Read the request line
109 | let mut request_line = String::new();
110 | buf_reader.read_line(&mut request_line).unwrap();
111 | let parts: Vec<&str> = request_line.split_whitespace().collect();
112 | let method = parts[0].to_string();
113 | let full_path = parts[1].to_string();
114 |
115 | let trim: &[_] = &['\r', '\n'];
116 | trace!("{}", request_line.trim_matches(trim));
117 |
118 | // Extract path and query parameters
119 | let (path, query_params) = match full_path.find('?') {
120 | Some(index) => {
121 | let (path, query_string) = full_path.split_at(index);
122 | let query_params: HashMap =
123 | form_urlencoded::parse(query_string[1..].as_bytes())
124 | .map(|(k, v)| (k.into_owned(), v.into_owned()))
125 | .collect();
126 | (path.to_string(), query_params)
127 | }
128 | None => (full_path, HashMap::new()),
129 | };
130 |
131 | // Read the headers
132 | let mut headers = Vec::new();
133 | loop {
134 | let mut header_line = String::new();
135 | buf_reader.read_line(&mut header_line).unwrap();
136 | if header_line.trim().is_empty() {
137 | break;
138 | }
139 | headers.push(header_line.trim().to_string());
140 | }
141 |
142 | // Check for Content-Length header
143 | let content_length: usize = headers
144 | .iter()
145 | .find_map(|header| {
146 | if header.to_lowercase().starts_with("content-length:") {
147 | header
148 | .split_whitespace()
149 | .last()
150 | .and_then(|len| len.parse().ok())
151 | } else {
152 | None
153 | }
154 | })
155 | .unwrap_or(0);
156 |
157 | // Read the body if Content-Length is present
158 | let mut body = String::new();
159 | if content_length > 0 {
160 | let mut body_bytes = vec![0u8; content_length];
161 | buf_reader.read_exact(&mut body_bytes).unwrap();
162 | body = String::from_utf8_lossy(&body_bytes).to_string();
163 | }
164 |
165 | let mut request = Request {
166 | headers,
167 | method,
168 | path,
169 | version: "HTTP/1.1".to_string(),
170 | body,
171 | query_params,
172 | path_params: HashMap::new(),
173 | };
174 |
175 | // Handle the request and get the response
176 | let response = handler.handle(&mut request);
177 |
178 | let http_response = format!(
179 | "HTTP/1.1 {} {}\r\n{}\r\n\r\n{}",
180 | response.status,
181 | get_status_text(response.status),
182 | get_headers(response.headers, &response.body),
183 | String::from_utf8_lossy(&response.body)
184 | );
185 |
186 | // Write the HTTP response to the stream
187 | stream.write_all(http_response.as_bytes()).unwrap();
188 | }
189 |
190 | /// Returns the final headers string including content-length and other defaults.
191 | fn get_headers(user_headers: Vec, body: &Vec) -> String {
192 | let mut headers = Vec::new();
193 | headers.push("Server: kurv".to_string());
194 | headers.push(format!("Content-Length: {}", body.len()));
195 | headers.push(format!("Date: {}", chrono::Utc::now().to_rfc2822()));
196 | headers.extend(user_headers);
197 | headers.join("\r\n")
198 | }
199 |
--------------------------------------------------------------------------------
/src/kurv/egg/load.rs:
--------------------------------------------------------------------------------
1 | use std::{fs::File, path::PathBuf};
2 |
3 | use super::Egg;
4 | use anyhow::{anyhow, Context, Result};
5 | use log::debug;
6 |
7 | impl Egg {
8 | pub fn load(path: PathBuf) -> Result {
9 | if !path.exists() {
10 | debug!(".kurv file not found, starting fresh (searched in {})", path.display());
11 | debug!("you can set KURV_HOME to change the directory");
12 | return Err(anyhow!(format!("file {} not found", path.display())));
13 | }
14 |
15 | let rdr = File::open(&path)
16 | .with_context(|| format!("failed to open egg file: {}", path.display()))?;
17 |
18 | let mut egg: Egg = serde_yaml::from_reader(rdr)
19 | .context(format!("failed to parse egg file: {}", path.display()))?;
20 |
21 | // remove id if it has one, so that it doesn't collide with an existing egg
22 | // the server will assign an ID automatically when spawning.
23 | egg.id = None;
24 |
25 | Ok(egg)
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/src/kurv/egg/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod load;
2 |
3 | use {
4 | chrono::prelude::*,
5 | chrono::Duration,
6 | serde::{Deserialize, Serialize},
7 | std::{collections::HashMap, path::PathBuf},
8 | };
9 |
10 | /// defines the status of an egg
11 | #[derive(PartialEq, Eq, Serialize, Deserialize, Clone, Copy, Debug)]
12 | pub enum EggStatus {
13 | Pending,
14 | Running,
15 | Stopped,
16 | PendingRemoval,
17 | Restarting,
18 | Errored,
19 | }
20 |
21 | fn default_pid() -> u32 {
22 | 0
23 | }
24 |
25 | /// defines the current state of an egg
26 | #[derive(PartialEq, Eq, Clone, Serialize, Deserialize)]
27 | pub struct EggState {
28 | pub status: EggStatus,
29 | pub start_time: Option>,
30 | pub try_count: u32,
31 | pub error: Option,
32 |
33 | #[serde(default = "default_pid")]
34 | pub pid: u32,
35 | }
36 |
37 | /// partial EggState used as a temporal struct to update the final EggState
38 | pub struct EggStateUpsert {
39 | pub status: Option,
40 | pub start_time: Option>,
41 | pub try_count: Option,
42 | pub error: Option,
43 | pub pid: Option,
44 | }
45 |
46 | #[derive(PartialEq, Eq, Clone, Serialize, Deserialize)]
47 | pub struct EggPaths {
48 | pub stdout: PathBuf,
49 | pub stderr: PathBuf,
50 | }
51 |
52 | /// 🥚 » an egg represents a process that can be started and stopped by kurv
53 | #[derive(PartialEq, Eq, Clone, Serialize, Deserialize)]
54 | pub struct Egg {
55 | pub command: String,
56 | pub name: String,
57 |
58 | /// unique id of the egg
59 | #[serde(skip_serializing_if = "Option::is_none")]
60 | pub id: Option,
61 |
62 | #[serde(skip_serializing_if = "Option::is_none")]
63 | pub state: Option,
64 |
65 | /// arguments to be passed to the command
66 | #[serde(skip_serializing_if = "Option::is_none")]
67 | pub args: Option>,
68 |
69 | /// working directory at which the command will be run
70 | #[serde(skip_serializing_if = "Option::is_none")]
71 | pub cwd: Option,
72 |
73 | /// environment variables to be set before running the command
74 | #[serde(skip_serializing_if = "Option::is_none")]
75 | pub env: Option>,
76 |
77 | /// paths to the stdout and stderr log files
78 | #[serde(skip_serializing_if = "Option::is_none")]
79 | pub paths: Option,
80 | }
81 |
82 | impl Egg {
83 | /// checks that the `egg` has a `state` or
84 | /// creates a new one if it doesn't.
85 | fn validate_state(&mut self) {
86 | if self.state.is_none() {
87 | self.state = Some(EggState {
88 | status: EggStatus::Pending,
89 | start_time: None,
90 | try_count: 0,
91 | error: None,
92 | pid: 0,
93 | });
94 | }
95 | }
96 |
97 | /// if `self` already has a `state`, it will be updated,
98 | /// otherwise a new `EggState` will be created for the `egg`.
99 | ///
100 | /// `EggStateUpsert` is a temporal struct
101 | /// that allows to update **only** the fields that are not `None`.
102 | pub fn upsert_state(&mut self, state: EggStateUpsert) {
103 | if let Some(ref mut egg_state) = self.state {
104 | if let Some(status) = state.status {
105 | egg_state.status = status;
106 | }
107 | if let Some(start_time) = state.start_time {
108 | egg_state.start_time = Some(start_time);
109 | }
110 | if let Some(try_count) = state.try_count {
111 | egg_state.try_count = try_count;
112 | }
113 | if let Some(error) = state.error {
114 | egg_state.error = Some(error);
115 | }
116 | if let Some(pid) = state.pid {
117 | egg_state.pid = pid;
118 | }
119 | } else {
120 | self.state = Some(EggState {
121 | status: state.status.unwrap_or(EggStatus::Pending),
122 | start_time: state.start_time,
123 | try_count: state.try_count.unwrap_or(0),
124 | error: state.error,
125 | pid: state.pid.unwrap_or(0),
126 | });
127 | }
128 | }
129 |
130 | /// sets the `status` of the `egg` to the given `status`.
131 | pub fn set_status(&mut self, status: EggStatus) {
132 | self.validate_state();
133 |
134 | if let Some(ref mut egg_state) = self.state {
135 | egg_state.status = status;
136 | }
137 | }
138 |
139 | /// increments the `try_count` of the `egg` by 1.
140 | pub fn increment_try_count(&mut self) {
141 | self.validate_state();
142 |
143 | if let Some(ref mut egg_state) = self.state {
144 | egg_state.try_count += 1;
145 | }
146 | }
147 |
148 | /// resets the `try_count` of the `egg` to 0.
149 | pub fn reset_try_count(&mut self) {
150 | self.validate_state();
151 |
152 | if let Some(ref mut egg_state) = self.state {
153 | egg_state.try_count = 0;
154 | }
155 | }
156 |
157 | /// sets the `pid` of the `egg` to the given `pid`.
158 | pub fn set_pid(&mut self, pid: u32) {
159 | self.validate_state();
160 |
161 | // set the pid if the egg has a state
162 | if let Some(ref mut egg_state) = self.state {
163 | egg_state.pid = pid;
164 | }
165 | }
166 |
167 | // sets the `error` of the `egg` to the given `error`.
168 | pub fn set_error(&mut self, error: String) {
169 | self.validate_state();
170 |
171 | // set the error if the egg has a state
172 | if let Some(ref mut egg_state) = self.state {
173 | egg_state.error = Some(error);
174 | }
175 | }
176 |
177 | /// sets the `start_time` of the `egg` to the current time.
178 | pub fn reset_start_time(&mut self) {
179 | self.validate_state();
180 |
181 | // set the start time if the egg has a state
182 | if let Some(ref mut egg_state) = self.state {
183 | egg_state.start_time = Some(Local::now());
184 | }
185 | }
186 |
187 | /// sets the `start_time` of the `egg`
188 | pub fn set_start_time(&mut self, time: Option>) {
189 | self.validate_state();
190 |
191 | // set the start time if the egg has a state
192 | if let Some(ref mut egg_state) = self.state {
193 | egg_state.start_time = time;
194 | }
195 | }
196 |
197 | /// marks the `egg` as running by:
198 | /// - setting the `pid` of the `egg` to the given `pid`.
199 | /// - setting the `start_time` of the `egg` to the current time.
200 | /// - resetting the `try_count` of the `egg` to 0.
201 | /// - setting the `status` of the `egg` to `EggStatus::Running`.
202 | pub fn set_as_running(&mut self, pid: u32) {
203 | self.set_pid(pid);
204 | self.reset_start_time();
205 | self.set_status(EggStatus::Running);
206 | self.set_error("".to_string());
207 | }
208 |
209 | /// marks the `egg` as errored by:
210 | pub fn set_as_errored(&mut self, error: String) {
211 | self.set_error(error);
212 | self.set_status(EggStatus::Errored);
213 | self.set_pid(0);
214 | self.increment_try_count();
215 | }
216 |
217 | /// marks the `egg` as stopped by:
218 | pub fn set_as_stopped(&mut self) {
219 | if !self.is_pending_removal() {
220 | self.set_status(EggStatus::Stopped);
221 | }
222 |
223 | self.set_pid(0);
224 | self.reset_try_count();
225 | self.set_start_time(None);
226 | }
227 |
228 | /// resets the `egg` to its initial state
229 | pub fn reset_state(&mut self) {
230 | self.set_status(EggStatus::Pending);
231 | self.set_error("".to_string());
232 | self.set_pid(0);
233 | self.set_start_time(None);
234 | }
235 |
236 | /// checks if the `egg` should be spawned
237 | /// (if its state is `Pending` or `Errored`).
238 | ///
239 | /// if it doesn't have a state, it should be spawned, as it's probably
240 | /// a new egg that has just been added.
241 | pub fn should_spawn(&self) -> bool {
242 | if let Some(ref egg_state) = self.state {
243 | egg_state.status == EggStatus::Pending || egg_state.status == EggStatus::Errored
244 | } else {
245 | true
246 | }
247 | }
248 |
249 | pub fn has_been_running_for(&self, duration: Duration) -> bool {
250 | if let Some(ref egg_state) = self.state {
251 | if let Some(start_time) = egg_state.start_time {
252 | let now = Local::now();
253 | let diff = now.signed_duration_since(start_time);
254 | diff > duration
255 | } else {
256 | false
257 | }
258 | } else {
259 | false
260 | }
261 | }
262 |
263 | /// checks if the `egg` is running
264 | /// (if its state is `Running`).
265 | pub fn is_running(&self) -> bool {
266 | self.is_in_status(EggStatus::Running)
267 | }
268 |
269 | /// checks if the `egg` is stopped
270 | /// (if its state is `Stopped`).
271 | pub fn is_stopped(&self) -> bool {
272 | self.is_in_status(EggStatus::Stopped)
273 | }
274 |
275 | /// checks if the `egg` is pending removal
276 | /// (if its state is `PendingRemoval`).
277 | pub fn is_pending_removal(&self) -> bool {
278 | self.is_in_status(EggStatus::PendingRemoval)
279 | }
280 |
281 | /// checks if the `egg` is restarting
282 | /// (if its state is `Restarting`).
283 | pub fn is_restarting(&self) -> bool {
284 | self.is_in_status(EggStatus::Restarting)
285 | }
286 |
287 | /// checks if the `egg` is in the given `status`.
288 | pub fn is_in_status(&self, status: EggStatus) -> bool {
289 | if let Some(ref egg_state) = self.state {
290 | egg_state.status == status
291 | } else {
292 | false
293 | }
294 | }
295 | }
296 |
--------------------------------------------------------------------------------
/src/kurv/kill.rs:
--------------------------------------------------------------------------------
1 | use log::warn;
2 |
3 | use {
4 | super::Kurv,
5 | log::{debug, error},
6 | };
7 |
8 | impl Kurv {
9 | /// checks each egg looking for those that are still running but that were
10 | /// marked as stopped from the api. In case it finds such a case, then it
11 | /// kills the background process of the egg that's supposed to be stopped.
12 | pub(crate) fn check_stopped_eggs(&mut self) -> bool {
13 | let state = self.state.clone();
14 | let mut state = state.lock().unwrap();
15 | let mut unsynced: bool = false;
16 |
17 | for (_, egg) in state.eggs.iter_mut() {
18 | // if the egg is not stopped or pending removal, continue
19 |
20 | let is_pending_removal = egg.is_pending_removal();
21 | let is_stopped = egg.is_stopped();
22 | let is_restarting = egg.is_restarting();
23 |
24 | if !is_stopped && !is_pending_removal && !is_restarting {
25 | continue;
26 | }
27 |
28 | // if the egg doesn't have an id, it means it hasn't been spawned yet
29 | // so, we won't need to stop anything, continue.
30 | let id = match egg.id {
31 | Some(id) => id,
32 | None => {
33 | continue;
34 | }
35 | };
36 |
37 | if let Some(child) = self.workers.get_child_mut(id) {
38 | // check if the egg is actually running when it shouldn't
39 | match child.inner().try_wait() {
40 | Ok(None) => {
41 | // it's still running, let's kill the mf
42 | match child.kill() {
43 | Err(ref e) if e.kind() == std::io::ErrorKind::InvalidData => {
44 | warn!("egg {} has already finished by itself.", egg.name);
45 | }
46 | Err(err) => {
47 | error!("error while stopping egg {}: {}", egg.name, err);
48 | }
49 | _ => {}
50 | }
51 |
52 | // we should also remove the child from the workers map and
53 | // set the egg as stopped (clear its pid, etc, not just the state)
54 | self.workers.remove_child(None, egg.name.clone());
55 |
56 | if is_restarting {
57 | egg.reset_state();
58 | } else {
59 | egg.set_as_stopped();
60 | }
61 |
62 | unsynced = true;
63 | debug!("egg {} has been stopped", egg.name);
64 | }
65 | Ok(_) => {
66 | // it's stopped, but we still have it in the workers for some
67 | // odd reason (shouldn't happen)... well, let's remove it.
68 | self.workers.remove_child(None, egg.name.clone());
69 | // just in case...
70 | egg.set_as_stopped();
71 | unsynced = true;
72 |
73 | debug!("egg {} is stopped but was still on the workers list, it has now been removed", egg.name);
74 | }
75 | Err(e) => {
76 | error!("error while waiting for child process {}: {}", id, e);
77 | continue;
78 | }
79 | }
80 | } else {
81 | // there's not child yet, it might've started as Stopped or PendingRemoval
82 | // let's clean status to show that there nothing running
83 | // - set_as_stopped will change status to Stopped only if current status is
84 | // not PendingRemoval. This will allow the removal to take place.
85 | egg.set_as_stopped();
86 | }
87 | }
88 |
89 | unsynced
90 | }
91 |
92 | /// checks each egg looking for those that has its removal pending
93 | /// and removes them from the state.
94 | pub(crate) fn check_removal_pending_eggs(&mut self) -> bool {
95 | let state = self.state.clone();
96 | let mut state = state.lock().unwrap();
97 | let mut unsynced: bool = false;
98 |
99 | let eggs = state.eggs.clone();
100 |
101 | for (_, egg) in eggs {
102 | // if the is not pending removal, continue
103 | if !egg.is_pending_removal() {
104 | continue;
105 | }
106 |
107 | let _ = state.remove(egg.id.unwrap());
108 |
109 | debug!("egg {} has been removed", egg.name);
110 | unsynced = true
111 | }
112 |
113 | unsynced
114 | }
115 | }
116 |
--------------------------------------------------------------------------------
/src/kurv/mod.rs:
--------------------------------------------------------------------------------
1 | mod egg;
2 | mod kill;
3 | mod spawn;
4 | mod state;
5 | mod stdio;
6 | mod workers;
7 |
8 | pub use egg::Egg;
9 | pub use egg::EggState;
10 | pub use egg::EggStatus;
11 |
12 | use {
13 | crate::common::Info,
14 | anyhow::Result,
15 | command_group::CommandGroup,
16 | egg::EggStateUpsert,
17 | state::KurvState,
18 | std::process::Command,
19 | std::sync::{Arc, Mutex},
20 | std::thread::sleep,
21 | std::time::Duration,
22 | stdio::clean_log_handles,
23 | stdio::create_log_file_handles,
24 | workers::Workers,
25 | };
26 |
27 | pub type KurvStateMtx = Arc>;
28 | pub type InfoMtx = Arc>;
29 |
30 | /// encapsulates the main functionality of the server side application
31 | pub struct Kurv {
32 | pub info: InfoMtx,
33 | pub state: KurvStateMtx,
34 | pub workers: Workers,
35 | }
36 |
37 | impl Kurv {
38 | /// creates a new instance of the kurv server
39 | pub fn new(info: InfoMtx, state: KurvStateMtx) -> Kurv {
40 | Kurv {
41 | info,
42 | state,
43 | workers: Workers::new(),
44 | }
45 | }
46 |
47 | /// main loop of the server, it runs twice a second and checks the state
48 | /// of the app:
49 | /// - if there are any new eggs to spawn (eggs with state `Errored` or `Pending`),
50 | /// try to spawn them
51 | /// - checks if all the running eggs are still actually running, and if not,
52 | /// change their state to `Pending` or `Errored` depending on the reason and
53 | /// remove them from the `workers` list so that they can be re-started on the
54 | /// next tick
55 | /// - check if all eggs that were marked as stopped are actually stopped and
56 | /// kill them otherwise
57 | pub fn run(&mut self) {
58 | loop {
59 | // each check returns an "unsynced" flag that tell us wether the state
60 | // has changed and we need to sync state with its file system file.
61 | // this avoids unnecesary write operations
62 | let mut unsynced = false;
63 |
64 | unsynced = self.spawn_all() || unsynced;
65 | unsynced = self.check_running_eggs() || unsynced;
66 | unsynced = self.check_stopped_eggs() || unsynced;
67 |
68 | // removal needs to happen after stops, to avoid orphans
69 | unsynced = self.check_removal_pending_eggs() || unsynced;
70 |
71 | if unsynced {
72 | // let state = self.state.clone();
73 | let state = self.state.lock().unwrap();
74 | let info = self.info.lock().unwrap();
75 | state.save(&info.paths.kurv_file).unwrap();
76 | }
77 |
78 | // sleep for a bit, we don't want to destroy the cpu
79 | sleep(Duration::from_millis(500));
80 | }
81 | }
82 |
83 | /// loads application state from .kurv file.
84 | ///
85 | /// this should only be called on bootstrap, as it will expect all
86 | /// eggs to not be running
87 | pub fn collect() -> Result<(InfoMtx, KurvStateMtx)> {
88 | let info = Info::new();
89 | let mut state = KurvState::load(info.paths.kurv_file.clone()).unwrap();
90 |
91 | // replace running eggs to Pending status, so they are started
92 | // on bootstra
93 | for (_, egg) in state.eggs.iter_mut() {
94 | if let Some(ref mut state) = egg.state {
95 | if state.status == EggStatus::Running {
96 | state.status = EggStatus::Pending;
97 | }
98 | }
99 | }
100 |
101 | Ok((Arc::new(Mutex::new(info)), Arc::new(Mutex::new(state))))
102 | }
103 | }
104 |
--------------------------------------------------------------------------------
/src/kurv/spawn.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::{egg::EggPaths, *},
3 | chrono::Duration,
4 | command_group::GroupChild,
5 | log::{debug, error, warn},
6 | };
7 |
8 | impl Kurv {
9 | /// try to spawn all eggs that are in `Pending` or `Errored` state
10 | pub(crate) fn spawn_all(&mut self) -> bool {
11 | let state = self.state.clone();
12 | let mut state = state.lock().unwrap();
13 | let mut unsynced = false;
14 |
15 | let mut eggs = state.eggs.clone();
16 | for (key, egg) in eggs.iter_mut() {
17 | // if the egg is errored or pending, try to spawn it
18 | if egg.should_spawn() {
19 | let (updated_egg, child) = self.spawn_egg(egg);
20 |
21 | // update original egg in state.eggs with the new values
22 | state.eggs.insert(key.clone(), updated_egg);
23 |
24 | // if child is Some, add it to the workers
25 | if let Some(child) = child {
26 | // so, we have a running egg, let's add it to the worker
27 | self.workers
28 | .add_child(None, key.clone(), egg.id.unwrap(), child);
29 | }
30 |
31 | unsynced = true;
32 | }
33 | }
34 |
35 | unsynced
36 | }
37 |
38 | /// checks each eggs looking for those that have finished running unexpectedly
39 | /// and sets their state accordingly. Also keeps re-try count updated
40 | pub(crate) fn check_running_eggs(&mut self) -> bool {
41 | let state = self.state.clone();
42 | let mut state = state.lock().unwrap();
43 | let mut unsynced: bool = false;
44 |
45 | for (_, egg) in state.eggs.iter_mut() {
46 | // if the egg is not running, then it was probably already checked
47 | if !egg.is_running() {
48 | continue;
49 | }
50 |
51 | // if the egg doesn't have an id, it means it hasn't been spawned yet
52 | let id = match egg.id {
53 | Some(id) => id,
54 | None => {
55 | continue;
56 | }
57 | };
58 |
59 | if let Some(child) = self.workers.get_child_mut(id) {
60 | // check that the child is still running
61 | match child.inner().try_wait() {
62 | Ok(None) => {
63 | // if it has been running for more than 5 seconds, we can assume
64 | // it started correctly and reset the try count just in case
65 | if egg.has_been_running_for(Duration::seconds(5)) {
66 | egg.reset_try_count();
67 | }
68 | }
69 | Ok(Some(status)) => {
70 | // yikes, the egg has exited, let's update its state
71 | let exit_err_msg: String = match status.code() {
72 | Some(code) => format!("Exited with code {}", code),
73 | None => "Exited with unknown code".to_string(),
74 | };
75 |
76 | // try to get the try count from the egg
77 | let try_count = match &egg.state {
78 | Some(state) => state.try_count,
79 | None => 0,
80 | };
81 |
82 | warn!(
83 | "egg {} exited: {} [#{}]",
84 | egg.name, exit_err_msg, try_count
85 | );
86 |
87 | egg.set_as_errored(exit_err_msg);
88 | unsynced = true
89 | }
90 | Err(e) => {
91 | error!("error while waiting for child process {}: {}", id, e);
92 | continue;
93 | }
94 | }
95 | }
96 | }
97 |
98 | unsynced
99 | }
100 |
101 | /// spawns the given `egg` and adds it to the `workers` list
102 | fn spawn_egg(&mut self, egg: &Egg) -> (Egg, Option) {
103 | let info = &self.info.lock().unwrap();
104 | let mut egg = egg.clone();
105 | let egg_name = egg.name.clone();
106 | let log_dir = info.paths.kurv_home.clone();
107 |
108 | let ((stdout_path, stdout_log), (stderr_path, stderr_log)) =
109 | match create_log_file_handles(&egg_name, &log_dir) {
110 | Ok((stdout_log, stderr_log)) => (stdout_log, stderr_log),
111 | Err(err) => {
112 | panic!("failed to create log file handles: {}", err)
113 | }
114 | };
115 |
116 | egg.paths = Some(EggPaths {
117 | stdout: stdout_path,
118 | stderr: stderr_path,
119 | });
120 |
121 | let (command, cwd, args, envs) = {
122 | (
123 | egg.command.clone(),
124 | match egg.cwd.clone() {
125 | Some(cwd) => cwd,
126 | None => info.paths.working_dir.clone(),
127 | },
128 | egg.args.clone(),
129 | egg.env.clone(),
130 | )
131 | };
132 |
133 | // Chain the args method call directly to the Command creation and configuration
134 | let process = Command::new(command)
135 | .current_dir(cwd)
136 | .stdout(stdout_log)
137 | .stderr(stderr_log)
138 | .args(args.unwrap_or_else(Vec::new))
139 | .envs(envs.unwrap_or_else(std::collections::HashMap::new))
140 | .group_spawn();
141 |
142 | // check if it has been spawned correctly
143 | let child = match process {
144 | Ok(child) => child,
145 | Err(err) => {
146 | let error = format!("failed to spawn child {egg_name} with err: {err:?}");
147 | error!("{}", error);
148 | clean_log_handles(&egg_name, &log_dir);
149 |
150 | // Update all necessary fields on the task.
151 | egg.upsert_state(EggStateUpsert {
152 | status: Some(egg::EggStatus::Errored),
153 | error: Some(error),
154 | pid: Some(0),
155 | start_time: None,
156 | try_count: None,
157 | });
158 |
159 | // Increment the try count
160 | egg.increment_try_count();
161 |
162 | // Return the updated egg
163 | return (egg, None);
164 | }
165 | };
166 |
167 | egg.set_as_running(child.id());
168 |
169 | debug!("spawned egg {} ", egg.name);
170 |
171 | (egg, Some(child))
172 | }
173 | }
174 |
--------------------------------------------------------------------------------
/src/kurv/state/eggs.rs:
--------------------------------------------------------------------------------
1 | use {
2 | super::KurvState,
3 | crate::kurv::egg::Egg,
4 | anyhow::{anyhow, Result},
5 | };
6 |
7 | impl KurvState {
8 | /// 🥚 » adds a new `egg` to the state and **returns** its assigned `id`
9 | pub fn collect(&mut self, egg: &Egg) -> usize {
10 | // from self.eggs find the one with the highest egg.id
11 | let next_id = self
12 | .eggs.values().map(|egg| egg.id.unwrap_or(0))
13 | .max()
14 | .unwrap_or(0)
15 | + 1;
16 |
17 | let mut new_egg = egg.clone();
18 | new_egg.id = Some(next_id);
19 | self.eggs.insert(egg.name.clone(), new_egg);
20 |
21 | next_id
22 | }
23 |
24 | /// 🥚 » retrieves the egg with the given `id` from the state
25 | pub fn get(&self, id: usize) -> Option<&Egg> {
26 | self.eggs.values().find(|&e| e.id == Some(id))
27 | }
28 |
29 | /// 🥚 » retrieves the egg with the given `id` from the state as a mutable reference
30 | pub fn get_mut(&mut self, id: usize) -> Option<&mut Egg> {
31 | self.eggs.iter_mut().map(|(_, e)| e).find(|e| e.id == Some(id))
32 | }
33 |
34 | /// 🥚 » retrieves the egg with the given `name` from the state
35 | pub fn get_by_name(&self, name: &str) -> Option<&Egg> {
36 | self.eggs.get(name)
37 | }
38 |
39 | /// 🥚 » retrieves the egg with the given `pid` from the state
40 | pub fn get_by_pid(&self, pid: u32) -> Option<&Egg> {
41 | self.eggs.values().find(|&e| e.state.is_some() && e.state.as_ref().unwrap().pid == pid)
42 | }
43 |
44 | // 🥚 » returns `true` if there's an agg with name `key`
45 | pub fn contains_key(&self, key: String) -> bool {
46 | self.eggs.contains_key(&key)
47 | }
48 |
49 | /// 🥚 » retrieves the `egg.id` with the given token; the token can be:
50 | /// - the id of the egg (as a string)
51 | /// - the pid of the running egg
52 | /// - the name (key) of the egg
53 | pub fn get_id_by_token(&self, token: &str) -> Option {
54 | // Try to parse the token as usize to check if it's an id
55 | if let Ok(id) = token.parse::() {
56 | if let Some(egg) = self.get(id) {
57 | return egg.id;
58 | }
59 | }
60 |
61 | // Try to find an egg with the given pid and return its id
62 | if let Ok(pid) = token.parse::() {
63 | if let Some(egg) = self.get_by_pid(pid) {
64 | return egg.id;
65 | }
66 | }
67 |
68 | // Check if the token corresponds to an egg name and return its id
69 | if let Some(egg) = self.get_by_name(token) {
70 | return egg.id;
71 | }
72 |
73 | // If no match found, return None
74 | None
75 | }
76 |
77 | /// 🥚 » removes the egg with the given `name` from the state, and returns it
78 | ///
79 | /// **warn:** this will raise an error if the egg is still running. So, make sure to
80 | /// kill it first.
81 | pub fn remove(&mut self, id: usize) -> Result {
82 | if let Some(egg) = self.get(id).cloned() {
83 | // check that egg.state.pid is None
84 | if let Some(state) = egg.state.clone() {
85 | if state.pid > 0 {
86 | return Err(anyhow!(
87 | "Egg '{}' is still running with pid {}, please stop it first",
88 | egg.name,
89 | state.pid
90 | ));
91 | }
92 | }
93 |
94 | self.eggs.remove(&egg.name);
95 | Ok(egg)
96 | } else {
97 | Err(anyhow!("Egg with id '{}' not found", id))
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/src/kurv/state/mod.rs:
--------------------------------------------------------------------------------
1 | pub mod eggs;
2 |
3 | use {
4 | super::egg::Egg,
5 | crate::common::str::ToString,
6 | anyhow::Context,
7 | anyhow::Result,
8 | log::debug,
9 | serde::{Deserialize, Serialize},
10 | std::{collections::BTreeMap, fs::File, path::PathBuf},
11 | };
12 |
13 | /// KurvState encapsulates the state of the server side application
14 | /// It's serialized to disk as a YAML file and loaded on startup
15 | #[derive(PartialEq, Eq, Clone, Deserialize, Serialize)]
16 | pub struct KurvState {
17 | pub eggs: BTreeMap,
18 | }
19 |
20 | impl KurvState {
21 | /// tries to load the state from the given
22 | /// path, or creates a new one if it doesn't exist
23 | pub fn load(path: PathBuf) -> Result {
24 | if !path.exists() {
25 | debug!(".kurv file not found, starting fresh (searched in {})", path.display());
26 | debug!("you can set KURV_HOME to change the directory");
27 | return Ok(KurvState {
28 | eggs: BTreeMap::new(),
29 | });
30 | }
31 |
32 | let rdr = File::open(&path)
33 | .with_context(|| format!("failed to open eggs file: {}", path.display()))?;
34 |
35 | let mut state: KurvState = serde_yaml::from_reader(rdr)
36 | .context(format!("failed to parse eggs file: {}", path.display()))?;
37 |
38 | // check that all the eggs have an id and if not, assign one
39 | let mut next_id = 1;
40 | for (_, egg) in state.eggs.iter_mut() {
41 | if egg.id.is_none() {
42 | egg.id = Some(next_id);
43 | next_id += 1;
44 | } else {
45 | next_id = egg.id.unwrap() + 1;
46 | }
47 | }
48 |
49 | debug!("eggs collected");
50 |
51 | Ok(state)
52 | }
53 |
54 | /// saves the state to the given path
55 | pub fn save(&self, path: &PathBuf) -> Result<()> {
56 | let serialized = serde_yaml::to_string(&self)?;
57 | std::fs::write(path, serialized)?;
58 |
59 | let trim: &[_] = &['\r', '\n'];
60 | debug!("saving state to {}", path.str().trim_matches(trim));
61 |
62 | Ok(())
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/kurv/stdio.rs:
--------------------------------------------------------------------------------
1 | use {
2 | anyhow::anyhow,
3 | anyhow::Result,
4 | log::error,
5 | std::fs::File,
6 | std::fs::{create_dir_all, OpenOptions},
7 | std::path::{Path, PathBuf},
8 | };
9 |
10 | /// The type of an stdio file.
11 | enum StdioFile {
12 | Stdout,
13 | Stderr,
14 | }
15 |
16 | /// Create and return the two file handles for the `(stdout, stderr)` log file of a task.
17 | /// These are two handles to the same file.
18 | pub fn create_log_file_handles(
19 | task_name: &str,
20 | path: &Path,
21 | ) -> Result<((PathBuf, File), (PathBuf, File))> {
22 | let (stdout_path, stderr_path) = get_log_paths(task_name, path);
23 | let stdout_handle = create_or_append_file(&stdout_path)?;
24 | let stderr_handle = create_or_append_file(&stderr_path)?;
25 |
26 | Ok(((stdout_path, stdout_handle), (stderr_path, stderr_handle)))
27 | }
28 |
29 | /// creates a file or opens it for appending if it already exists
30 | fn create_or_append_file(path: &Path) -> Result {
31 | if let Some(parent) = path.parent() {
32 | create_dir_all(parent).map_err(|err| anyhow!("failed to create directories: {}", err))?;
33 | }
34 |
35 | OpenOptions::new()
36 | .create(true)
37 | .append(true)
38 | .open(path)
39 | .map_err(|err| anyhow!("getting stdout handle: {}", err))
40 | }
41 |
42 | /// Get the path to the log file of a task.
43 | pub fn get_log_paths(task_name: &str, path: &Path) -> (PathBuf, PathBuf) {
44 | let task_log_dir = path.join("task_logs");
45 |
46 | (
47 | task_log_dir.join(stdio_filename(task_name, StdioFile::Stdout)),
48 | task_log_dir.join(stdio_filename(task_name, StdioFile::Stderr)),
49 | )
50 | }
51 |
52 | /// Get the filename of the log file of a task.
53 | fn stdio_filename(task_name: &str, file_type: StdioFile) -> String {
54 | // make task_name kebab-case
55 | task_name.to_owned().clone()
56 | + match file_type {
57 | StdioFile::Stdout => ".stdout",
58 | StdioFile::Stderr => ".stderr",
59 | }
60 | }
61 |
62 | /// Remove the the log files of a task.
63 | pub fn clean_log_handles(task_name: &String, path: &Path) {
64 | let (stdout_path, stderr_path) = get_log_paths(task_name, path);
65 |
66 | if stdout_path.exists() {
67 | if let Err(err) = std::fs::remove_file(stdout_path) {
68 | error!("Failed to remove stdout file for task {task_name} with error {err:?}");
69 | };
70 | }
71 |
72 | if stderr_path.exists() {
73 | if let Err(err) = std::fs::remove_file(stderr_path) {
74 | error!("Failed to remove stderr file for task {task_name} with error {err:?}");
75 | };
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/src/kurv/workers.rs:
--------------------------------------------------------------------------------
1 | // shamelessly stolen from the pueue project (original name Children)
2 | // it doesn't provide any advantage over using a simple HashMap or BTreeMap, for now
3 | // but it might come in handy later on, if we want to implement running multiple workers/
4 | // instances of an egg at the same time (like a cluster)
5 |
6 | use command_group::GroupChild;
7 | use std::collections::BTreeMap;
8 |
9 | /// This structure is needed to manage worker pools for groups.
10 | /// It's a newtype pattern around a nested BTreeMap, which implements some convenience functions.
11 | ///
12 | /// The datastructure represents the following data:
13 | /// BTreeMap
14 | pub struct Workers(pub BTreeMap>);
15 |
16 | const DEFAULT_GROUP: &str = "default_kurv";
17 |
18 | impl Workers {
19 | /// Creates a new worker pool with a single default group.
20 | pub fn new() -> Self {
21 | let mut pools = BTreeMap::new();
22 | pools.insert(String::from(DEFAULT_GROUP), BTreeMap::new());
23 |
24 | Workers(pools)
25 | }
26 |
27 | /// A convenience function to get a mutable child by its respective task_id.
28 | /// We have to do a nested linear search over all children of all pools,
29 | /// beceause these datastructure aren't indexed via task_ids.
30 | pub fn get_child_mut(&mut self, task_id: usize) -> Option<&mut GroupChild> {
31 | for pool in self.0.values_mut() {
32 | for (child_task_id, child) in pool.values_mut() {
33 | if child_task_id == &task_id {
34 | return Some(child);
35 | }
36 | }
37 | }
38 |
39 | None
40 | }
41 |
42 | /// Inserts a new children into the worker pool of the given group.
43 | ///
44 | /// This function should only be called when spawning a new process.
45 | /// At this point, we're sure that the worker pool for the given group already exists, hence
46 | /// the expect call.
47 | pub fn add_child(
48 | &mut self,
49 | group: Option<&str>,
50 | worker_id: String,
51 | task_id: usize,
52 | child: GroupChild,
53 | ) {
54 | let group = group.unwrap_or(DEFAULT_GROUP);
55 |
56 | let pool = self
57 | .0
58 | .get_mut(group)
59 | .expect("The worker pool should be initialized when inserting a new child.");
60 |
61 | pool.insert(worker_id, (task_id, child));
62 | }
63 |
64 | /// Removes a child from the given group (or the default group if `group == None`).
65 | pub fn remove_child(&mut self, group: Option<&str>, worker_id: String) {
66 | let group = group.unwrap_or(DEFAULT_GROUP);
67 |
68 | let pool = self
69 | .0
70 | .get_mut(group)
71 | .expect("The worker pool should be initialized when removing a child.");
72 |
73 | pool.remove(&worker_id);
74 | }
75 | }
76 |
--------------------------------------------------------------------------------
/src/main.rs:
--------------------------------------------------------------------------------
1 | use std::process::exit;
2 |
3 | use indoc::formatdoc;
4 | use pico_args::Arguments;
5 |
6 | mod api;
7 | mod cli;
8 | mod common;
9 | mod kurv;
10 |
11 | use {
12 | crate::cli::components::{Component, Logo},
13 | anyhow::Result,
14 | cli::dispatch_command,
15 | cli::DispatchResult,
16 | common::log::Logger,
17 | kurv::Kurv,
18 | log::Level,
19 | std::thread,
20 | };
21 |
22 | fn main() -> Result<()> {
23 | Logger::init(Level::Trace)?;
24 |
25 | match dispatch_command()? {
26 | DispatchResult::Dispatched => Ok(()),
27 | DispatchResult::Server => {
28 | if !can_run_as_server() {
29 | exit(1);
30 | }
31 |
32 | printth!("{}", (Logo {}).render());
33 | let (info, state) = Kurv::collect()?;
34 |
35 | // start the api server on its own thread
36 | let api_info = info.clone();
37 | let api_state = state.clone();
38 |
39 | thread::spawn(move || {
40 | api::start(api_info, api_state);
41 | });
42 |
43 | // 🏃 run forest, run!
44 | Kurv::new(info.clone(), state.clone()).run();
45 | Ok(())
46 | }
47 | }
48 | }
49 |
50 | /// check if the app can run as a server
51 | ///
52 | /// the app can run as a server if the KURV_SERVER env var is set to true
53 | fn can_run_as_server() -> bool {
54 | // check that the KURV_SERVER env var is set to true
55 | let mut arguments = Arguments::from_env();
56 | if arguments.contains("--force") {
57 | return true;
58 | }
59 |
60 | match std::env::var("KURV_SERVER") {
61 | Ok(val) => val == "true",
62 | Err(_) => {
63 | printth!(
64 | "{}",
65 | formatdoc! {"
66 |
67 | [error] to be able to run kurv as a server, the KURV_SERVER env var must be
68 | set to true .
69 |
70 | why though?
71 | since kurv cli can run both as a server and as a client using the same
72 | executable, it might be the case that you want be sure that you won't
73 | accidentally launch the server when you meant to launch the client.
74 |
75 | So, to be able to run it as a server, you must explicitly set the
76 | KURV_SERVER environment variable to true clearly indicate your intention.
77 |
78 | You can bypass this check by sending the --force flag to the cli.
79 | "}
80 | );
81 |
82 | false
83 | }
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/🧺 kurv.code-workspace:
--------------------------------------------------------------------------------
1 | {
2 | "folders": [
3 | {
4 | "path": "."
5 | }
6 | ],
7 | "settings": {
8 | "terminal.integrated.env.windows": {
9 | // allows to run kurv command in terminal for development purposes
10 | "PATH": "${workspaceFolder}/target/debug;${env:PATH}",
11 | "KURV_SERVER": "true",
12 | "KURV_HOME": "${workspaceFolder}"
13 | },
14 | "lucodear-icons.files.associations": {
15 | "*.task_logs/stdout": "log",
16 | "*.task_logs/stderr": "log",
17 | },
18 | "lucodear-icons.activeIconPack": "rust_ferris_minimal"
19 | }
20 | }
--------------------------------------------------------------------------------