├── .cocorc ├── .github ├── actions │ └── check-version │ │ ├── Dockerfile │ │ ├── action.yml │ │ └── version_checker.py ├── icon-logo-h64.ai ├── icon-logo-h64.svg ├── kurv.gif └── workflows │ ├── ci.yml │ ├── draft-release.yml │ └── publish.yml ├── .gitignore ├── .kurvrc ├── .vscode ├── launch.json ├── settings.json └── tasks.json ├── Cargo.toml ├── LICENSE ├── README.md ├── Taskfile.yaml ├── check_size.py ├── src ├── api │ ├── eggs.rs │ ├── err.rs │ ├── mod.rs │ └── status.rs ├── cli │ ├── cmd │ │ ├── api │ │ │ ├── eggs.rs │ │ │ └── mod.rs │ │ ├── collect.rs │ │ ├── default.rs │ │ ├── egg.rs │ │ ├── list.rs │ │ ├── mod.rs │ │ ├── server_help.rs │ │ └── stop_start.rs │ ├── color │ │ ├── mod.rs │ │ ├── style.rs │ │ └── theme.rs │ ├── components │ │ ├── help.rs │ │ ├── logo.rs │ │ └── mod.rs │ └── mod.rs ├── common │ ├── duration.rs │ ├── info.rs │ ├── log.rs │ ├── mod.rs │ ├── str.rs │ └── tcp │ │ └── mod.rs ├── kurv │ ├── egg │ │ ├── load.rs │ │ └── mod.rs │ ├── kill.rs │ ├── mod.rs │ ├── spawn.rs │ ├── state │ │ ├── eggs.rs │ │ └── mod.rs │ ├── stdio.rs │ └── workers.rs └── main.rs └── 🧺 kurv.code-workspace /.cocorc: -------------------------------------------------------------------------------- 1 | # https://github.com/lucas-labs/coco config 2 | 3 | askScope: false 4 | useEmoji: true -------------------------------------------------------------------------------- /.github/actions/check-version/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3-slim 2 | RUN pip install --upgrade pip 3 | RUN pip install requests tomli packaging 4 | 5 | COPY version_checker.py /version_checker.py 6 | 7 | ENTRYPOINT ["python3", "/version_checker.py"] -------------------------------------------------------------------------------- /.github/actions/check-version/action.yml: -------------------------------------------------------------------------------- 1 | # action.yaml 2 | name: "poetry project configuration" 3 | description: "get information about poetry project: version, name, description, etc." 4 | branding: 5 | icon: 'aperture' 6 | color: 'green' 7 | inputs: 8 | cargo-toml-path: 9 | description: "location of cargo toml file" 10 | required: true 11 | default: "./cargo.toml" 12 | outputs: 13 | is-local-higher: 14 | description: "True if local version is higher than public version" 15 | local-version: 16 | description: "Local version of the package" 17 | published-version: 18 | description: "Public version of the package" 19 | package-name: 20 | description: "Name of the package" 21 | package-description: 22 | description: "Description of the package" 23 | 24 | runs: 25 | using: 'docker' 26 | image: 'Dockerfile' 27 | args: 28 | - ${{ inputs.cargo-toml-path }} -------------------------------------------------------------------------------- /.github/actions/check-version/version_checker.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | import tomli 5 | from packaging import version 6 | 7 | 8 | if __name__ == '__main__': 9 | toml_file_path = sys.argv[1] 10 | 11 | with open(toml_file_path, 'rb') as f: 12 | project = tomli.load(f) 13 | 14 | print(project) 15 | 16 | name = project.get('package', {}).get('name', None) 17 | project_version = version.parse(project.get('package', {}).get('version', None)) 18 | description = project.get('package', {}).get('description', None) 19 | 20 | with open(os.environ['GITHUB_OUTPUT'], 'at') as f: 21 | f.write(f'local-version={str(project_version)}\n') 22 | f.write(f'package-name={name}\n') 23 | f.write(f'package-description={description}\n') 24 | -------------------------------------------------------------------------------- /.github/icon-logo-h64.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucas-labs/kurv/b887c9c3d3f12b2612468384e633f3a59af20491/.github/icon-logo-h64.ai -------------------------------------------------------------------------------- /.github/icon-logo-h64.svg: -------------------------------------------------------------------------------- 1 | 2 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /.github/kurv.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucas-labs/kurv/b887c9c3d3f12b2612468384e633f3a59af20491/.github/kurv.gif -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: "🔄 ci & publish" 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | paths-ignore: 8 | - '**.md' 9 | - .editorconfig 10 | - .gitignore 11 | pull_request: 12 | branches: 13 | - "*" 14 | 15 | concurrency: 16 | group: ci-${{ github.ref }} 17 | cancel-in-progress: true 18 | 19 | jobs: 20 | draft_release: 21 | if: 22 | github.event_name == 'push' && 23 | ( 24 | startsWith(github.event.head_commit.message, 'release:') || 25 | startsWith(github.event.head_commit.message, 'release(') 26 | ) 27 | uses: ./.github/workflows/draft-release.yml 28 | secrets: inherit 29 | 30 | -------------------------------------------------------------------------------- /.github/workflows/draft-release.yml: -------------------------------------------------------------------------------- 1 | name: "🔖 » generate draft release" 2 | on: 3 | workflow_call: 4 | workflow_dispatch: 5 | 6 | concurrency: 7 | group: release-${{ github.ref }} 8 | cancel-in-progress: true 9 | 10 | defaults: 11 | run: 12 | shell: 'bash' 13 | 14 | jobs: 15 | release: 16 | name: "🔖 » draft release" 17 | runs-on: ubuntu-latest 18 | outputs: 19 | release-note: ${{ steps.changelog.outputs.changelog }} 20 | version: ${{ steps.version.outputs.local-version }} 21 | 22 | steps: 23 | - name: 📁 » checkout repository 24 | uses: actions/checkout@v3 25 | with: 26 | fetch-depth: 0 27 | 28 | - name: ⬅️ » get previous git tag 29 | id: tag 30 | run: echo "last-tag=$(git describe --tags --abbrev=0 || git rev-list --max-parents=0 ${{github.ref}})" >> $GITHUB_OUTPUT 31 | 32 | - name: 🏷️ » get versions 33 | uses: ./.github/actions/check-version 34 | id: version 35 | with: 36 | cargo-toml-path: "./Cargo.toml" 37 | 38 | - name: 📑 » generate changelog 39 | uses: lucaslabstech/action-release@v1.0.4 40 | id: changelog 41 | with: 42 | token: ${{ secrets.GITHUB_TOKEN }} 43 | from: ${{ steps.tag.outputs.last-tag }} 44 | to: ${{ github.ref }} 45 | next-version: v${{ steps.version.outputs.local-version }} 46 | 47 | - name: 🗑️ » delete outdated drafts 48 | uses: hugo19941994/delete-draft-releases@v1.0.1 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | 52 | - name: 🔖 » create draft release 53 | uses: softprops/action-gh-release@v1 54 | env: 55 | GITHUB_TOKEN: ${{ secrets.github_token }} 56 | with: 57 | prerelease: false 58 | draft: true 59 | tag_name: v${{ steps.version.outputs.local-version }} 60 | name: v${{ steps.version.outputs.local-version }} 61 | body: ${{ steps.changelog.outputs.changelog }} 62 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: "🚀 » publish" 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | concurrency: 8 | group: publish-${{ github.ref }} 9 | cancel-in-progress: true 10 | 11 | defaults: 12 | run: 13 | shell: 'bash' 14 | 15 | jobs: 16 | build: 17 | name: 🏗️ » build 18 | runs-on: ${{ matrix.platform.os }} 19 | strategy: 20 | fail-fast: false 21 | matrix: 22 | platform: 23 | - os_name: linux-x86_64 24 | os: ubuntu-latest 25 | target: x86_64-unknown-linux-gnu 26 | bin: kurv 27 | archive: kurv-linux-x86_64.tar.gz 28 | 29 | - os_name: windows-x86_64 30 | os: windows-latest 31 | target: x86_64-pc-windows-msvc 32 | bin: kurv.exe 33 | archive: kurv-windows-x86_64.zip 34 | steps: 35 | - name: 📁 » checkout repository 36 | uses: actions/checkout@v3 37 | with: 38 | fetch-depth: 0 39 | 40 | - name: 🗃️ » cache cargo 41 | uses: Swatinem/rust-cache@v2 42 | 43 | - name: 🏗️ » build kurv 44 | uses: houseabsolute/actions-rust-cross@v0 45 | with: 46 | cross-version: 47 | command: "build" 48 | target: ${{ matrix.platform.target }} 49 | args: "--release" 50 | 51 | - name: 📦 » archive kurv release 52 | shell: bash 53 | run: | 54 | cd target/${{ matrix.platform.target }}/release 55 | 56 | if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then 57 | 7z a ../../../${{ matrix.platform.archive }} ${{ matrix.platform.bin }} 58 | else 59 | tar czvf ../../../${{ matrix.platform.archive }} ${{ matrix.platform.bin }} 60 | fi 61 | cd - 62 | 63 | - name: 📤 » upload artifact 64 | uses: actions/upload-artifact@v4 65 | with: 66 | name: kurv-${{ matrix.platform.os_name }} 67 | path: ${{ matrix.platform.archive }} 68 | 69 | 70 | publish-gh-release: 71 | name: 🚀 » publish github release 72 | needs: build 73 | runs-on: ubuntu-latest 74 | steps: 75 | - name: 📥 » download artifacts 76 | uses: actions/download-artifact@v4 77 | with: 78 | pattern: kurv-* 79 | merge-multiple: true 80 | 81 | - name: 📁 » list files 82 | run: ls -l -a -R 83 | 84 | - name: 🚀 » upload artifacts to github release 85 | uses: softprops/action-gh-release@v1 86 | with: 87 | files: | 88 | kurv-* 89 | 90 | publish-crates-io: 91 | name: 🚀 » publish crates.io 92 | needs: build 93 | runs-on: ubuntu-latest 94 | steps: 95 | - name: 📁 » checkout repository 96 | uses: actions/checkout@v3 97 | with: 98 | fetch-depth: 0 99 | 100 | - name: 🦀 » install rust 101 | uses: dtolnay/rust-toolchain@stable 102 | 103 | - name: 🚀 » publish 104 | uses: katyo/publish-crates@v2 105 | with: 106 | registry-token: ${{ secrets.CRATES_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ### Rust ### 2 | # Generated by Cargo 3 | # will have compiled files and executables 4 | debug/ 5 | target/ 6 | 7 | # Remove Cargo.lock from gitignore if creating an executable, leave it for libraries 8 | # More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html 9 | Cargo.lock 10 | 11 | # These are backup files generated by rustfmt 12 | **/*.rs.bk 13 | 14 | # MSVC Windows builds of rustc generate these, which store debugging information 15 | *.pdb 16 | 17 | ### rust-analyzer ### 18 | # Can be generated by other build systems other than cargo (ex: bazelbuild/rust_rules) 19 | rust-project.json 20 | 21 | ### Custom ### 22 | *.bkp 23 | *.kurv 24 | task_logs/ 25 | .task 26 | 27 | ### Visual Studio Code ### 28 | .vscode/* 29 | !.vscode/settings.json 30 | !.vscode/tasks.json 31 | !.vscode/launch.json 32 | !.vscode/extensions.json 33 | .logs/ 34 | 35 | ### CI ### 36 | .github/act-test -------------------------------------------------------------------------------- /.kurvrc: -------------------------------------------------------------------------------- 1 | api: 2 | # the host and port that the API server will listen on 3 | host: localhost 4 | port: 58787 5 | 6 | # if a request is made from any host that's not 7 | # in the allow_hosts list, it will be rejected 8 | allowed_hosts: 9 | - localhost 10 | 11 | cors: 12 | allow_credentials: true 13 | allow_origins: 14 | - http://localhost:3000 15 | allow_methods: 16 | - GET 17 | - POST 18 | allow_headers: 19 | - Content-Type 20 | - Authorization 21 | - X-Requested-With 22 | - X-CSRF-TOKEN 23 | - Cache-Control 24 | expose_headers: 25 | - Content-Type 26 | - Content-Length 27 | - ETag 28 | max_age: 3600 29 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | 8 | // debug rust on windows 9 | { 10 | "name": "Debug Rust on Windows", 11 | "type": "cppvsdbg", 12 | "request": "launch", 13 | "program": "${workspaceFolder}/target/debug/${workspaceFolderBasename}.exe", 14 | "args": [ 15 | "server", 16 | "--force" 17 | ], 18 | "stopAtEntry": false, 19 | "cwd": "${workspaceFolder}", 20 | "environment": [ 21 | { 22 | "name": "KURV_HOME", 23 | "value": "${workspaceFolder}" 24 | } 25 | ], 26 | "externalConsole": true, 27 | "preLaunchTask": "cargo: build" 28 | }, 29 | ] 30 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "files.exclude": { 3 | // 😺 git 4 | ".git": true, 5 | ".gitignore": true, 6 | // ".github": true, 7 | 8 | // 📦 packages 9 | "Cargo.lock": true, 10 | 11 | // 🏗️ build 12 | "target": true, 13 | "check_size.py": true, 14 | 15 | // 📝 docs 16 | "README.md": true, 17 | 18 | // 🟦 vscode 19 | ".vscode": true, 20 | "*.code-workspace": true, 21 | 22 | // 🗑️ trash 23 | ".task": true, 24 | }, 25 | "[rust]": { 26 | "editor.formatOnSave": true 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | // See https://go.microsoft.com/fwlink/?LinkId=733558 3 | // for the documentation about the tasks.json format 4 | "version": "2.0.0", 5 | "tasks": [ 6 | { 7 | "label": "cargo: build", 8 | "type": "shell", 9 | "command": "cargo build", 10 | "group": { 11 | "kind": "build", 12 | "isDefault": true 13 | }, 14 | "problemMatcher": [ 15 | "$rustc" 16 | ] 17 | }, 18 | ] 19 | } -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "kurv" 3 | version = "0.0.4" 4 | edition = "2021" 5 | description = "A process manager to daemonize commands and programs. Inspired by pm2, but lightweight and not as featureful." 6 | authors = ["Lucas Colombo"] 7 | categories = ["command-line-utilities"] 8 | keywords = ["cli", "process", "manager", "daemon", "daemonize"] 9 | homepage = "https://kurv.lucode.ar" 10 | license = "MIT" 11 | license-file = "LICENSE" 12 | readme = "README.md" 13 | repository = "https://github.com/lucas-labs/kurv" 14 | 15 | [profile.release] 16 | strip = true 17 | lto = true 18 | codegen-units = 1 19 | opt-level = "z" 20 | panic = "abort" 21 | rpath = false 22 | overflow-checks = false 23 | debug = 0 24 | debug-assertions = false 25 | 26 | [dependencies] 27 | anyhow = "1.0.75" 28 | chrono = { version="0.4.31", features = ["serde"] } 29 | cli-table = "0.4.7" 30 | command-group = "5.0.1" 31 | crossterm = "0.27.0" 32 | form_urlencoded = "1.2.1" 33 | htmlparser = "0.1.1" 34 | indoc = "2.0.4" 35 | log = "0.4.20" 36 | pico-args = "0.5.0" 37 | regex-lite = "0.1.5" 38 | serde = { version = "1.0.193", features = ["derive"] } 39 | serde_json = "1.0.108" 40 | serde_yaml = "0.9.27" 41 | velcro = "0.5.4" 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Lucas Colombo 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 |

Crates.io Version

11 | 12 |

DocsCrateReadme

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 | ![kurv usage](.github/kurv.gif) 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}>» {}", date, record.args()); 39 | } 40 | _ => { 41 | printth!("{} <{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 | } --------------------------------------------------------------------------------