├── .envrc ├── example ├── start │ ├── .ruby-version │ ├── .env │ ├── config │ │ └── puma.rb │ ├── .bundle │ │ └── config │ ├── fixtures │ │ ├── exit_1.sh │ │ ├── loop.sh │ │ └── exit_0.sh │ ├── Gemfile │ ├── config.ru │ ├── Procfile │ ├── Gemfile.lock │ └── README.md ├── run │ ├── .env │ ├── fixtures │ │ ├── exit_0.sh │ │ ├── exit_1.sh │ │ └── loop.sh │ ├── Procfile │ └── README.md ├── check │ ├── .env │ ├── fixtures │ │ ├── exit_1.sh │ │ ├── loop.sh │ │ └── exit_0.sh │ ├── Procfile │ └── README.md └── export │ ├── .env │ ├── fixtures │ ├── exit_0.sh │ ├── exit_1.sh │ └── loop.sh │ ├── Procfile │ ├── setup │ ├── supervisord.sh │ └── runit.sh │ ├── Dockerfile │ ├── docker-compose.yml │ └── README.md ├── .gitignore ├── test └── fixtures │ ├── env.sh │ ├── exit_0.sh │ ├── exit_1.sh │ ├── loop.sh │ └── for.sh ├── docs ├── ctrl_c.drawio.png └── DEVELOP.md ├── src ├── cmd │ ├── export │ │ ├── templates │ │ │ ├── upstart │ │ │ │ ├── master.conf.hbs │ │ │ │ ├── process_master.conf.hbs │ │ │ │ └── process.conf.hbs │ │ │ ├── daemon │ │ │ │ ├── process_master.conf.hbs │ │ │ │ ├── master.conf.hbs │ │ │ │ └── process.conf.hbs │ │ │ ├── systemd │ │ │ │ ├── master.target.hbs │ │ │ │ └── process.service.hbs │ │ │ ├── runit │ │ │ │ ├── run.hbs │ │ │ │ └── log │ │ │ │ │ └── run.hbs │ │ │ ├── supervisord │ │ │ │ └── app.conf.hbs │ │ │ └── launchd │ │ │ │ └── launchd.plist.hbs │ │ ├── base.rs │ │ ├── launchd.rs │ │ ├── systemd.rs │ │ ├── supervisord.rs │ │ ├── upstart.rs │ │ ├── runit.rs │ │ ├── daemon.rs │ │ └── mod.rs │ ├── mod.rs │ ├── completion.rs │ ├── check.rs │ ├── run.rs │ └── start.rs ├── main.rs ├── log │ ├── plain.rs │ ├── color.rs │ └── mod.rs ├── env.rs ├── opt.rs ├── output.rs ├── stream_read.rs ├── config.rs ├── process.rs ├── signal.rs └── procfile.rs ├── .github ├── ISSUE_TEMPLATE.md ├── workflows │ ├── build_nix.yml │ ├── publish.yml │ ├── periodic.yml │ ├── regression.yml │ ├── release.yml │ └── update-homebrew.yml └── PULL_REQUEST_TEMPLATE.md ├── default.nix ├── docker-compose.yml ├── flake.nix ├── LICENSE ├── CHANGELOG.md ├── containers └── Dockerfile ├── scripts └── update_homebrew.sh ├── Makefile ├── Cargo.toml ├── flake.lock ├── CLAUDE.md ├── README.md ├── .claude └── commands │ └── commit.md └── man └── main.rs /.envrc: -------------------------------------------------------------------------------- 1 | use flake 2 | -------------------------------------------------------------------------------- /example/start/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /example/run/.env: -------------------------------------------------------------------------------- 1 | MESSAGE="Hello World" 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | result 2 | .direnv/ 3 | target/ 4 | -------------------------------------------------------------------------------- /example/check/.env: -------------------------------------------------------------------------------- 1 | MESSAGE="Hello World" 2 | -------------------------------------------------------------------------------- /example/export/.env: -------------------------------------------------------------------------------- 1 | MESSAGE="Hello World" 2 | -------------------------------------------------------------------------------- /example/start/.env: -------------------------------------------------------------------------------- 1 | MESSAGE="Hello World" 2 | PORT=5000 3 | -------------------------------------------------------------------------------- /example/start/config/puma.rb: -------------------------------------------------------------------------------- 1 | workers 3 2 | preload_app! 3 | -------------------------------------------------------------------------------- /example/start/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_PATH: "vendor/bundle" 3 | -------------------------------------------------------------------------------- /test/fixtures/env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | export PORT=3200 3 | echo $PORT 4 | -------------------------------------------------------------------------------- /test/fixtures/exit_0.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 3 && echo 'success' && exit 0; 4 | -------------------------------------------------------------------------------- /test/fixtures/exit_1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 2 && echo 'failed' && exit 1; 4 | -------------------------------------------------------------------------------- /test/fixtures/loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while :; do sleep 1 && echo $1; done; 4 | -------------------------------------------------------------------------------- /example/check/fixtures/exit_1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 2 && echo 'failed' && exit 1; 4 | -------------------------------------------------------------------------------- /example/check/fixtures/loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while :; do sleep 1 && echo $1; done; 4 | -------------------------------------------------------------------------------- /example/run/fixtures/exit_0.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 3 && echo 'success' && exit 0; 4 | -------------------------------------------------------------------------------- /example/run/fixtures/exit_1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 2 && echo 'failed' && exit 1; 4 | -------------------------------------------------------------------------------- /example/run/fixtures/loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while :; do sleep 1 && echo $1; done; 4 | -------------------------------------------------------------------------------- /example/start/fixtures/exit_1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 2 && echo 'failed' && exit 1; 4 | -------------------------------------------------------------------------------- /example/start/fixtures/loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while :; do sleep 1 && echo $1; done; 4 | -------------------------------------------------------------------------------- /docs/ctrl_c.drawio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yukihirop/ultraman/HEAD/docs/ctrl_c.drawio.png -------------------------------------------------------------------------------- /example/check/fixtures/exit_0.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 3 && echo 'success' && exit 0; 4 | -------------------------------------------------------------------------------- /example/export/fixtures/exit_0.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 3 && echo 'success' && exit 0; 4 | -------------------------------------------------------------------------------- /example/export/fixtures/exit_1.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 2 && echo 'failed' && exit 1; 4 | -------------------------------------------------------------------------------- /example/export/fixtures/loop.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | while :; do sleep 1 && echo $1; done; 4 | -------------------------------------------------------------------------------- /example/start/fixtures/exit_0.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sleep 3 && echo 'success' && exit 0; 4 | -------------------------------------------------------------------------------- /example/export/Procfile: -------------------------------------------------------------------------------- 1 | exit_0: ./exit_0.sh 2 | exit_1: ./exit_1.sh 3 | loop: ./loop.sh $MESSAGE 4 | -------------------------------------------------------------------------------- /src/cmd/export/templates/upstart/master.conf.hbs: -------------------------------------------------------------------------------- 1 | start on runlevel [2345] 2 | stop on runlevel [!2345] 3 | -------------------------------------------------------------------------------- /src/cmd/mod.rs: -------------------------------------------------------------------------------- 1 | pub mod check; 2 | pub mod completion; 3 | pub mod export; 4 | pub mod run; 5 | pub mod start; -------------------------------------------------------------------------------- /example/start/Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | ruby '3.2.2' 3 | gem 'nio4r', '2.7.0' 4 | gem 'puma' 5 | -------------------------------------------------------------------------------- /test/fixtures/for.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | for i in 1 2 3 4 | do 5 | sleep 1 6 | echo "${FOR_MSG} $i" 7 | done 8 | -------------------------------------------------------------------------------- /example/start/config.ru: -------------------------------------------------------------------------------- 1 | # config.ru 2 | run lambda { |env| [200, {"Content-Type" => "text/html"}, ["Hello World"]] } 3 | -------------------------------------------------------------------------------- /example/check/Procfile: -------------------------------------------------------------------------------- 1 | exit_0: ./fixtures/exit_0.sh 2 | exit_1: ./fixtures/exit_1.sh 3 | loop: ./fixtures/loop.sh $MESSAGE 4 | -------------------------------------------------------------------------------- /example/run/Procfile: -------------------------------------------------------------------------------- 1 | exit_0: ./fixtures/exit_0.sh 2 | exit_1: ./fixtures/exit_1.sh 3 | loop: ./fixtures/loop.sh $MESSAGE 4 | -------------------------------------------------------------------------------- /src/cmd/export/templates/daemon/process_master.conf.hbs: -------------------------------------------------------------------------------- 1 | {{#with process_master}} 2 | start on starting {{ app }} 3 | start on stopping {{ app }} 4 | {{/with}} 5 | -------------------------------------------------------------------------------- /src/cmd/export/templates/upstart/process_master.conf.hbs: -------------------------------------------------------------------------------- 1 | {{#with process_master}} 2 | start on starting {{ app }} 3 | stop on stopping {{ app }} 4 | {{/with}} 5 | -------------------------------------------------------------------------------- /src/cmd/export/templates/systemd/master.target.hbs: -------------------------------------------------------------------------------- 1 | {{#with master_target}} 2 | [Unit] 3 | Wants={{ service_names }} 4 | 5 | [Install] 6 | WantedBy=multi-user.target 7 | {{/with}} 8 | -------------------------------------------------------------------------------- /example/export/setup/supervisord.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | mkdir -p /var/log/app 3 | touch /var/log/app/{exit_0-1,exit_1-1,loop-1}.log 4 | touch /var/log/app/{exit_0-1,exit_1-1,loop-1}.error.log 5 | -------------------------------------------------------------------------------- /src/cmd/export/templates/runit/run.hbs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | {{#with run}} 3 | cd {{ work_dir }} 4 | exec 2>&1 5 | exec chpst -u {{ user }} -e {{ env_dir_path }} {{ process_command }} 6 | {{/with}} 7 | -------------------------------------------------------------------------------- /example/start/Procfile: -------------------------------------------------------------------------------- 1 | exit_0: ./fixtures/exit_0.sh 2 | exit_1: ./fixtures/exit_1.sh 3 | loop: ./fixtures/loop.sh $MESSAGE 4 | web: bundle exec puma -p $PORT -C ./config/puma.rb 5 | early_out: seq 100 6 | sleep: sleep 20 7 | -------------------------------------------------------------------------------- /src/cmd/export/templates/runit/log/run.hbs: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | set -e 3 | {{#with log_run}} 4 | LOG={{ log_path }} 5 | 6 | test -d "$LOG" || mkdir -p -m 2750 "$LOG" && chown {{ user }} "$LOG" 7 | exec chpst -u {{ user }} svlogd "$LOG" 8 | {{/with}} 9 | -------------------------------------------------------------------------------- /docs/DEVELOP.md: -------------------------------------------------------------------------------- 1 | # Development 2 | 3 | ## Example 4 | 5 | ```bash 6 | cd example/start 7 | cargo run start -p ./Procfile 8 | ``` 9 | 10 | ## Release 11 | 12 | ```bash 13 | cargo build 14 | cargo bump 15 | cargo publish --dry-run 16 | ``` 17 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Steps to reproduce 2 | 3 | ### Expected behavior 4 | Tell us what should happen 5 | 6 | ### Actual behavior 7 | Tell us what happens instead 8 | 9 | ### System configuration 10 | **ultraman version**: 11 | 12 | **Rust version**: 13 | -------------------------------------------------------------------------------- /default.nix: -------------------------------------------------------------------------------- 1 | (import ( 2 | fetchTarball { 3 | url = "https://github.com/edolstra/flake-compat/archive/99f1c2157fba4bfe6211a321fd0ee43199025dbf.tar.gz"; 4 | sha256 = "0x2jn3vrawwv9xp15674wjz9pixwjyj3j771izayl962zziivbx2"; } 5 | ) { 6 | src = ./.; 7 | }).defaultNix 8 | -------------------------------------------------------------------------------- /example/export/setup/runit.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | rm -rf /etc/service/{app-exit_0-1,app-exit_1-1,app-loop-1}/supervise/ 3 | mkdir -p /var/log/app/{exit_0-1,exit_1-1,loop-1} 4 | sudo chmod 0755 /etc/service/{app-exit_0-1,app-exit_1-1,app-loop-1}/run 5 | sudo chmod 0755 /etc/service/{app-exit_0-1,app-exit_1-1,app-loop-1}/log/run 6 | -------------------------------------------------------------------------------- /.github/workflows/build_nix.yml: -------------------------------------------------------------------------------- 1 | name: "Build legacy Nix package on Ubuntu" 2 | 3 | on: 4 | push: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - uses: actions/checkout@v2 11 | - uses: cachix/install-nix-action@v26 12 | - name: Building package 13 | run: nix build 14 | -------------------------------------------------------------------------------- /example/start/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | nio4r (2.7.0) 5 | puma (6.4.0) 6 | nio4r (~> 2.0) 7 | 8 | PLATFORMS 9 | arm64-darwin-22 10 | ruby 11 | 12 | DEPENDENCIES 13 | nio4r (= 2.7.0) 14 | puma 15 | 16 | RUBY VERSION 17 | ruby 3.2.2p53 18 | 19 | BUNDLED WITH 20 | 2.5.3 21 | -------------------------------------------------------------------------------- /src/cmd/export/templates/daemon/master.conf.hbs: -------------------------------------------------------------------------------- 1 | pre-start script 2 | {{#with master}} 3 | bash << "EOF" 4 | mkdir -p {{ log_dir_path }} 5 | chown -R {{ user }} {{ log_dir_path }} 6 | mkdir -p {{ run_dir_path }} 7 | chown -R {{ user }} {{ run_dir_path }} 8 | EOF 9 | {{/with}} 10 | end script 11 | 12 | start on runlevel [2345] 13 | 14 | stop on runlevel [016] 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.0" 2 | services: 3 | test_ubuntu: 4 | build: 5 | context: . 6 | dockerfile: './containers/Dockerfile' 7 | volumes: 8 | - .:/home/app 9 | - ./containers/registry:/usr/local/cargo/registry/ 10 | environment: 11 | - SHELL=/bin/bash 12 | entrypoint: /bin/bash -c "while :; do sleep 10; done" 13 | -------------------------------------------------------------------------------- /src/cmd/export/templates/upstart/process.conf.hbs: -------------------------------------------------------------------------------- 1 | {{#with process}} 2 | start on starting {{ app }}-{{ name }} 3 | stop on stopping {{ app }}-{{ name }} 4 | respawn 5 | 6 | env PORT={{ port }} 7 | {{#each env_without_port as |item| ~}} 8 | env {{ item.key }}='{{ item.value }}' 9 | {{/each~}} 10 | 11 | setuid {{ setuid }} 12 | 13 | chdir {{ chdir }} 14 | 15 | exec {{{ exec }}} 16 | {{/with}} 17 | -------------------------------------------------------------------------------- /src/cmd/export/templates/daemon/process.conf.hbs: -------------------------------------------------------------------------------- 1 | {{#with process}} 2 | start on starting {{ service_name }} 3 | stop on stopping {{ service_name }} 4 | respawn 5 | 6 | {{#each env as |item| ~}} 7 | env {{ item.key }}={{ item.value }} 8 | {{/each ~}} 9 | 10 | exec start-stop-daemon --start --chuid {{ user }} --chdir {{ work_dir }} --make-pidfile --pidfile {{ pid_path }} --exec {{ command }}{{ command_args }}; >> {{ log_path }} 2>&1 11 | {{/with}} 12 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Summary 2 | 3 | Resolve #Issue 4 | 5 | ### Other Information 6 | 7 | If there's anything else that's important and relevant to your pull 8 | request, mention that information here. This could include 9 | benchmarks, or other information. 10 | 11 | If you are updating any of the CHANGELOG files or are asked to update the 12 | CHANGELOG files by reviewers, please add the CHANGELOG entry at the top of the file. 13 | 14 | Thanks for contributing to ultraman! 15 | 16 | 17 | ### Work 18 | 19 | 20 | ### Test 21 | -------------------------------------------------------------------------------- /example/export/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:16.04 as for_upstart 2 | RUN apt-get update && apt-get install -y sudo vim supervisor runit && rm -rf /var/lib/apt/lists/* 3 | ENV APP /home/app 4 | 5 | 6 | RUN mkdir -p ${APP}/setup && mkdir -p /etc/init /etc/systemd/system /etc/supervisor/conf.d 7 | 8 | # for supervisor 9 | # If this is not done, the following error will occur 10 | # 11 | # root@25e20da59668:/# supervisorctl version 12 | # unix:///var/run/supervisor.sock no such file 13 | RUN touch /etc/supervisord.conf 14 | 15 | WORKDIR ${APP} 16 | EXPOSE 80 17 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish Crate 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*' 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | timeout-minutes: 15 12 | steps: 13 | - uses: actions/checkout@v2 14 | - name: Install Rust 15 | uses: actions-rs/toolchain@v1 16 | with: 17 | toolchain: stable 18 | override: true 19 | - name: Publish to Crates.io 20 | uses: actions-rs/cargo@v1 21 | with: 22 | command: publish 23 | args: --token ${{ secrets.CARGO_REGISTRY_TOKEN }} 24 | -------------------------------------------------------------------------------- /src/cmd/export/templates/supervisord/app.conf.hbs: -------------------------------------------------------------------------------- 1 | [supervisord] 2 | nodaemon=true 3 | {{#with app_conf}} 4 | {{#each data as |item| ~}} 5 | [program:{{ item.program }}] 6 | command={{{ item.process_command }}} 7 | autostart=true 8 | autorestart=true 9 | stdout_logfile={{ item.stdout_logfile }} 10 | stderr_logfile={{ item.stderr_logfile }} 11 | user={{ item.user }} 12 | directory={{ item.work_dir }} 13 | ;Commented out because it doesn't load well 14 | ;environment={{{ item.environment }}} 15 | 16 | {{/each~}} 17 | 18 | [group:{{ app }}] 19 | programs={{ service_names }} 20 | {{/with}} 21 | -------------------------------------------------------------------------------- /src/cmd/export/templates/systemd/process.service.hbs: -------------------------------------------------------------------------------- 1 | {{#with process_service }} 2 | [Unint] 3 | PartOf={{ app }}.target 4 | StopWhenUnneeded=yes 5 | 6 | [Service] 7 | User={{ user }} 8 | WorkingDirectory={{ work_dir }} 9 | Environment=PORT={{ port }} 10 | Environment=PS={{ process_name }} 11 | {{#each env_without_port as |item| ~}} 12 | Environment="{{ item.key }}={{ item.value }}" 13 | {{/each~}} 14 | ExecStart=/bin/bash -lc 'exec -a "{{ app }}-{{ process_name }}" {{ process_command }}' 15 | Restart=always 16 | RestartSec=14s 17 | StandardInput=null 18 | StandardOutput=syslog 19 | StandardError=syslog 20 | SyslogIdentifier=%n 21 | KillMode=mixed 22 | TimeoutStopSec={{ timeout }} 23 | {{/with}} 24 | -------------------------------------------------------------------------------- /flake.nix: -------------------------------------------------------------------------------- 1 | { 2 | inputs = { 3 | naersk.url = "github:nix-community/naersk/master"; 4 | nixpkgs.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; 5 | utils.url = "github:numtide/flake-utils"; 6 | }; 7 | 8 | outputs = { self, nixpkgs, utils, naersk }: 9 | utils.lib.eachDefaultSystem (system: 10 | let 11 | pkgs = import nixpkgs { inherit system; }; 12 | naersk-lib = pkgs.callPackage naersk { }; 13 | in 14 | { 15 | defaultPackage = naersk-lib.buildPackage ./.; 16 | devShell = with pkgs; mkShell { 17 | buildInputs = [ cargo rustc rustfmt pre-commit rustPackages.clippy ]; 18 | RUST_SRC_PATH = rustPlatform.rustLibSrc; 19 | }; 20 | } 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /src/cmd/completion.rs: -------------------------------------------------------------------------------- 1 | use crate::opt::Opt; 2 | use std::io; 3 | use structopt::clap::Shell; 4 | use structopt::StructOpt; 5 | 6 | #[derive(StructOpt, Debug)] 7 | #[structopt(about = "Generate shell completion scripts")] 8 | #[structopt(setting(structopt::clap::AppSettings::ColoredHelp))] 9 | pub struct CompletionOpts { 10 | #[structopt( 11 | help = "Shell to generate completions for", 12 | possible_values = &["bash", "zsh", "fish", "powershell", "elvish"] 13 | )] 14 | pub shell: Shell, 15 | } 16 | 17 | pub fn run(opts: CompletionOpts) -> Result<(), Box> { 18 | let mut app = Opt::clap(); 19 | let app_name = app.get_name().to_string(); 20 | app.gen_completions_to(app_name, opts.shell, &mut io::stdout()); 21 | Ok(()) 22 | } -------------------------------------------------------------------------------- /.github/workflows/periodic.yml: -------------------------------------------------------------------------------- 1 | # reference: https://github.com/dalance/procs/blob/master/.github/workflows/periodic.yml 2 | 3 | name: Periodic 4 | 5 | on: 6 | schedule: 7 | - cron: 0 0 * * SUN 8 | 9 | jobs: 10 | test: 11 | 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest] 15 | rust: [stable, beta, nightly] 16 | 17 | runs-on: ${{ matrix.os }} 18 | timeout-minutes: 15 19 | 20 | steps: 21 | - name: Setup Rust 22 | uses: hecrj/setup-rust-action@v1 23 | with: 24 | rust-version: ${{ matrix.rust }} 25 | 26 | - name: Checkout 27 | uses: actions/checkout@v1 28 | 29 | - name: Run tests 30 | run: | 31 | cargo update 32 | make test 33 | shell: bash 34 | env: 35 | SHELL: /bin/bash 36 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | use opt::{Opt, Ultraman}; 2 | use structopt::StructOpt; 3 | 4 | mod cmd; 5 | mod config; 6 | mod env; 7 | mod log; 8 | mod opt; 9 | mod output; 10 | mod process; 11 | mod procfile; 12 | mod signal; 13 | mod stream_read; 14 | 15 | fn main() -> Result<(), Box> { 16 | let opt = Opt::from_args(); 17 | 18 | if let Some(subcommand) = opt.subcommands { 19 | match subcommand { 20 | Ultraman::Check(opts) => cmd::check::run(opts), 21 | Ultraman::Completion(opts) => cmd::completion::run(opts).expect("failed ultraman completion"), 22 | Ultraman::Start(opts) => cmd::start::run(opts).expect("failed ultraman start"), 23 | Ultraman::Run(opts) => cmd::run::run(opts), 24 | Ultraman::Export(opts) => cmd::export::run(opts).expect("failed ultraman export"), 25 | } 26 | } 27 | 28 | Ok(()) 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/regression.yml: -------------------------------------------------------------------------------- 1 | # reference: https://github.com/dalance/procs/blob/master/.github/workflows/regression.yml 2 | 3 | name: Regression 4 | 5 | on: [push, pull_request] 6 | 7 | jobs: 8 | test: 9 | 10 | strategy: 11 | matrix: 12 | os: [ubuntu-latest, macOS-latest] 13 | rust: [stable] 14 | 15 | runs-on: ${{ matrix.os }} 16 | timeout-minutes: 15 17 | if: contains(github.event.head_commit.message, '[skip ci]') == false 18 | 19 | steps: 20 | - name: Setup Rust 21 | uses: hecrj/setup-rust-action@v1 22 | with: 23 | rust-version: ${{ matrix.rust }} 24 | 25 | - name: Checkout 26 | uses: actions/checkout@v1 27 | 28 | - name: Run tests 29 | run: make test 30 | shell: bash 31 | env: 32 | SHELL: /bin/bash 33 | 34 | - name: Run tests feature variation 35 | run: make test-no-default-features 36 | shell: bash 37 | env: 38 | SHELL: /bin/bash 39 | -------------------------------------------------------------------------------- /src/cmd/export/templates/launchd/launchd.plist.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{#with launchd ~}} 6 | 7 | Label 8 | {{ label }} 9 | EnvironmentVariables 10 | 11 | {{#each env as |item| ~}} 12 | {{ item.key }} 13 | {{ item.value }} 14 | {{/each~}} 15 | 16 | ProgramArguments 17 | 18 | {{#each command_args as |item|~}} 19 | {{ item }} 20 | {{/each~}} 21 | 22 | KeepAlive 23 | 24 | RunAtLoad 25 | 26 | StandardOutPath 27 | {{ stdout_path }} 28 | StandardErrorPath 29 | {{ stderr_path }} 30 | UserName 31 | {{ user }} 32 | WorkingDirectory 33 | {{ work_dir }} 34 | 35 | 36 | {{/with}} 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | The MIT License (MIT) 3 | 4 | Copyright (c) 2020 yukihirop 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /src/log/plain.rs: -------------------------------------------------------------------------------- 1 | use crate::log::{now, Printable}; 2 | use crate::opt::DisplayOpts; 3 | 4 | #[derive(Default)] 5 | pub struct Log { 6 | pub index: usize, 7 | pub opts: DisplayOpts, 8 | } 9 | 10 | unsafe impl Sync for Log {} 11 | unsafe impl Send for Log {} 12 | 13 | // https://teratail.com/questions/244925 14 | impl Log { 15 | fn boxed(self) -> Box { 16 | Box::new(self) 17 | } 18 | 19 | pub fn boxed_new() -> Box { 20 | Self::default().boxed() 21 | } 22 | } 23 | 24 | impl Printable for Log { 25 | fn output(&self, proc_name: &str, content: &str) { 26 | if self.opts.is_timestamp { 27 | println!( 28 | "{3} {0:1$} | {2}", 29 | proc_name, 30 | self.opts.padding, 31 | content, 32 | now() 33 | ) 34 | } else { 35 | println!("{0:1$} | {2}", proc_name, self.opts.padding, content) 36 | } 37 | } 38 | 39 | fn error(&self, proc_name: &str, err: &dyn std::error::Error) { 40 | let content = &format!("error: {:?}", err); 41 | self.output(proc_name, content); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /example/export/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | services: 3 | export_upstart: 4 | build: . 5 | volumes: 6 | - ./tmp/upstart:/etc/init 7 | - ./fixtures:/home/app 8 | entrypoint: /bin/sh -c "initctl reload-configuration && while :; do sleep 10; done" 9 | export_systemd: 10 | build: . 11 | volumes: 12 | - ./tmp/systemd:/etc/systemd/system 13 | - ./fixtures:/home/app 14 | entrypoint: /bin/sh -c "initctl reload-configuration && while :; do sleep 10; done" 15 | export_supervisord: 16 | build: . 17 | volumes: 18 | - ./tmp/supervisord:/etc/supervisor/conf.d 19 | - ./fixtures:/home/app 20 | - ./setup:/home/app/setup 21 | entrypoint: /bin/sh -c "while :; do sleep 10; done" 22 | export_runit: 23 | build: . 24 | volumes: 25 | - ./tmp/runit:/etc/service 26 | - ./fixtures:/home/app 27 | - ./setup:/home/app/setup 28 | entrypoint: /bin/bash -c "while :; do sleep 10; done" 29 | export_daemon: 30 | build: . 31 | volumes: 32 | - ./tmp/daemon:/etc/init 33 | - ./fixtures:/home/app 34 | entrypoint: /bin/sh -c "initctl reload-configuration && while :; do sleep 10; done" 35 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # CHANGELOG 2 | 3 | ## v0.3.2 4 | 5 | - fix #55 6 | 7 | ## v0.3.1 8 | 9 | - [Breaking] fix `port_for` logic. 10 | - Ports may be specified explicitly, so I stopped adjusting them automatically with `app_index`. 11 | 12 | ```rs 13 | // before 14 | pub fn port_for(env_path: &PathBuf, port: Option, app_index: usize, concurrency_index: usize) -> u32 { 15 | base_port(env_path, port) + (app_index * 100 + concurrency_index) as u32 16 | } 17 | 18 | // after 19 | pub fn port_for(env_path: &PathBuf, port: Option, concurrency_index: usize) -> u32 { 20 | base_port(env_path, port) + concurrency_index 21 | } 22 | ``` 23 | 24 | ## v0.1.2 25 | 26 | - Refactor All 27 | - [Breaking] The output format of `ultraman export ` has changed 28 | - Add Badges 29 | 30 | Please see [milestone v0.1.2](https://github.com/yukihirop/ultraman/milestone/3?closed=1) 31 | 32 | ## v0.1.1 33 | 34 | 2020/12/24 35 | 36 | - Support to install by homebrew 37 | 38 | Please see [milestone v0.1.1](https://github.com/yukihirop/ultraman/milestone/2?closed=1) 39 | 40 | ## v0.1.0 41 | 42 | 2020/12/13 43 | 44 | First Release 🎉 45 | 46 | [foreman](https://github.com/ddollar/foreman)'s rust implementation 47 | 48 | - ultraman start 49 | - ultraman run 50 | - ultraman export 51 | 52 | Please see [milestone v0.1.0](https://github.com/yukihirop/ultraman/milestone/1?closed=1) 53 | -------------------------------------------------------------------------------- /example/check/README.md: -------------------------------------------------------------------------------- 1 | # Ultraman check example 2 | 3 | `ultraman check` checks if one or more processes are defined in the Procfile. 4 | It does not check the contents of the process. 5 | 6 | |short|long|default|description| 7 | |-----|----|-------|-----------| 8 | |-f|--procfile|`Procfile`|Specify an alternate Procfile to load| 9 | 10 | ## Example 11 | 12 | Here is an example when the `Procfile` and `.env` files have the following contents 13 | 14 | [Procfile] 15 | ``` 16 | exit_0: ./fixtures/exit_0.sh 17 | exit_1: ./fixtures/exit_1.sh 18 | loop: ./fixtures/loop.sh $MESSAGE 19 | ``` 20 | 21 | [.env] 22 | ``` 23 | MESSAGE="Hello World" 24 | ``` 25 | 26 | ## Full option example (short) 27 | 28 | ```bash 29 | cargo run check \ 30 | -f Procfile 31 | ``` 32 | 33 |
34 | 35 | ```bash 36 | valid procfile detected (exit_0, exit_1, loop) 37 | ``` 38 | 39 | ```bash 40 | echo $? 41 | 0 42 | ``` 43 | 44 |
45 | 46 | ### case Procfile do not exist 47 | 48 | ```bash 49 | cargo run check \ 50 | -f ./tmp/do_not_exist/Procfile 51 | ``` 52 | 53 |
54 | 55 | ```bash 56 | ./tmp/do_not_exist/Procfile does not exist. 57 | ``` 58 | 59 | ```bash 60 | echo $? 61 | 1 62 | ``` 63 | 64 |
65 | 66 | ## Full option example (long) 67 | 68 | ```bash 69 | cargo run check \ 70 | --procfile Procfile 71 | ``` 72 | 73 | ```bash 74 | valid procfile detected (exit_0, exit_1, loop) 75 | ``` 76 | 77 | ```bash 78 | echo $? 79 | 0 80 | ``` 81 | -------------------------------------------------------------------------------- /src/env.rs: -------------------------------------------------------------------------------- 1 | use dotenv; 2 | use std::collections::HashMap; 3 | use std::path::PathBuf; 4 | 5 | pub type Env = HashMap; 6 | 7 | pub fn read_env(filepath: PathBuf) -> Result> { 8 | let mut env: Env = HashMap::new(); 9 | 10 | if let Some(()) = dotenv::from_path(filepath.as_path()).ok() { 11 | let env_vars: Vec<(String, String)> = dotenv::vars().collect(); 12 | for pair in env_vars { 13 | let (key, val) = pair; 14 | env.insert(key, val); 15 | } 16 | return Ok(env); 17 | } 18 | Ok(env) 19 | } 20 | 21 | #[cfg(test)] 22 | mod tests { 23 | use super::*; 24 | use std::fs::File; 25 | use std::io::Write; 26 | use tempfile::tempdir; 27 | 28 | #[test] 29 | fn test_read_env() -> anyhow::Result<()> { 30 | let dir = tempdir()?; 31 | let file_path = dir.path().join(".env"); 32 | let mut file = File::create(file_path.clone())?; 33 | writeln!( 34 | file, 35 | r#" 36 | PORT=5000 37 | PS=1 38 | "# 39 | ) 40 | .unwrap(); 41 | 42 | let result = read_env(file_path).expect("failed read .env"); 43 | 44 | assert_eq!(result.get("PORT").unwrap(), "5000"); 45 | assert_eq!(result.get("PS").unwrap(), "1"); 46 | assert_ne!(result.get("CARGO_PKG_VERSION"), None); 47 | assert_eq!(result.get("DO_NOT_EXIST_ENV"), None); 48 | 49 | Ok(()) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /example/run/README.md: -------------------------------------------------------------------------------- 1 | # Ultraman run example 2 | 3 | `ultraman run` is used to run your application directly from the command line. 4 | If no additional parameters are passed, `ultraman` will run one instance of each type of process defined in your `Procfile`. 5 | If a parameter is passed, `ultraman` will run one instance of the specified application type. 6 | 7 | The following options control how the application is run: 8 | 9 | |short|long|default|description| 10 | |-----|----|-------|-----------| 11 | |-e|--env|`.env`|Specify an environment file to load| 12 | |-f|--procfile|`Procfile`|Specify an alternate Procfile to load| 13 | 14 | 15 | ## Example 16 | 17 | Here is an example when the `Procfile` and `.env` files have the following contents 18 | 19 | [Procfile] 20 | ``` 21 | exit_0: ./fixtures/exit_0.sh 22 | exit_1: ./fixtures/exit_1.sh 23 | loop: ./fixtures/loop.sh $MESSAGE 24 | ``` 25 | 26 | [.env] 27 | ``` 28 | MESSAGE="Hello World" 29 | ``` 30 | 31 | ## Full option example (short) 32 | 33 | ```bash 34 | cargo run run loop \ 35 | -e .env \ 36 | -f Procfile 37 | ``` 38 | 39 |
40 | 41 | ```bash 42 | Hello World 43 | Hello World 44 | Hello World 45 | Hello World 46 | Hello World 47 | Hello World 48 | Hello World 49 | Hello World 50 | ^C% 51 | ``` 52 | 53 |
54 | 55 | ## Full option example (long) 56 | 57 | ```bash 58 | cargo run run exit_0 \ 59 | --env .env \ 60 | --procfile Procfile 61 | ``` 62 | 63 | ```bash 64 | success 65 | ``` 66 | -------------------------------------------------------------------------------- /containers/Dockerfile: -------------------------------------------------------------------------------- 1 | # https://github.com/rust-lang/docker-rust/pull/57/files 2 | 3 | FROM buildpack-deps:bionic 4 | 5 | ENV RUSTUP_HOME=/usr/local/rustup \ 6 | CARGO_HOME=/usr/local/cargo \ 7 | PATH=/usr/local/cargo/bin:$PATH \ 8 | RUST_VERSION=1.41.0 9 | 10 | RUN set -eux; \ 11 | dpkgArch="$(dpkg --print-architecture)"; \ 12 | case "${dpkgArch##*-}" in \ 13 | amd64) rustArch='x86_64-unknown-linux-gnu'; rustupSha256='ad1f8b5199b3b9e231472ed7aa08d2e5d1d539198a15c5b1e53c746aad81d27b' ;; \ 14 | armhf) rustArch='armv7-unknown-linux-gnueabihf'; rustupSha256='6c6c3789dabf12171c7f500e06d21d8004b5318a5083df8b0b02c0e5ef1d017b' ;; \ 15 | arm64) rustArch='aarch64-unknown-linux-gnu'; rustupSha256='26942c80234bac34b3c1352abbd9187d3e23b43dae3cf56a9f9c1ea8ee53076d' ;; \ 16 | i386) rustArch='i686-unknown-linux-gnu'; rustupSha256='27ae12bc294a34e566579deba3e066245d09b8871dc021ef45fc715dced05297' ;; \ 17 | *) echo >&2 "unsupported architecture: ${dpkgArch}"; exit 1 ;; \ 18 | esac; \ 19 | url="https://static.rust-lang.org/rustup/archive/1.21.1/${rustArch}/rustup-init"; \ 20 | wget "$url"; \ 21 | echo "${rustupSha256} *rustup-init" | sha256sum -c -; \ 22 | chmod +x rustup-init; \ 23 | ./rustup-init -y --no-modify-path --profile minimal --default-toolchain $RUST_VERSION; \ 24 | rm rustup-init; \ 25 | chmod -R a+w $RUSTUP_HOME $CARGO_HOME; \ 26 | rustup --version; \ 27 | cargo --version; \ 28 | rustc --version; 29 | 30 | ENV APP /home/app 31 | RUN mkdir -p ${APP} 32 | WORKDIR ${APP} 33 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # reference: https://github.com/dalance/procs/blob/master/.github/workflows/release.yml 2 | 3 | name: Release 4 | 5 | on: 6 | workflow_dispatch: 7 | push: 8 | tags: 9 | - 'v*.*.*' 10 | 11 | jobs: 12 | build: 13 | strategy: 14 | matrix: 15 | include: 16 | - os: ubuntu-latest 17 | target: x86_64-unknown-linux-musl 18 | make_cmd: release_linux 19 | - os: macOS-latest 20 | target: x86_64-apple-darwin 21 | make_cmd: release_mac 22 | - os: macOS-latest 23 | target: aarch64-apple-darwin 24 | make_cmd: release_mac_arm 25 | # - os: windows-latest 26 | # target: x86_64-pc-windows-msvc 27 | # make_cmd: release_win 28 | 29 | runs-on: ${{ matrix.os }} 30 | timeout-minutes: 20 31 | 32 | steps: 33 | - name: Checkout 34 | uses: actions/checkout@v4 35 | 36 | - name: Setup Rust 37 | uses: dtolnay/rust-toolchain@stable 38 | with: 39 | targets: ${{ matrix.target }} 40 | 41 | - name: Setup MUSL 42 | if: matrix.os == 'ubuntu-latest' 43 | run: | 44 | sudo apt-get update -qq 45 | sudo apt-get install -qq musl-tools 46 | 47 | - name: Build release 48 | run: make ${{ matrix.make_cmd }} 49 | 50 | - name: Release 51 | uses: softprops/action-gh-release@v2 52 | with: 53 | files: "*.zip" 54 | generate_release_notes: true 55 | env: 56 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 57 | -------------------------------------------------------------------------------- /src/log/color.rs: -------------------------------------------------------------------------------- 1 | use crate::log::{now, Printable}; 2 | use crate::opt::DisplayOpts; 3 | use colored::*; 4 | 5 | const COLORS: [&str; 12] = [ 6 | "yellow", 7 | "cyan", 8 | "magenta", 9 | "white", 10 | "red", 11 | "green", 12 | "bright_yellow", 13 | "bright_magenta", 14 | "bright_cyan", 15 | "bright_white", 16 | "bright_red", 17 | "bright_green", 18 | ]; 19 | 20 | #[derive(Default)] 21 | pub struct Log { 22 | pub index: usize, 23 | pub opts: DisplayOpts, 24 | } 25 | 26 | unsafe impl Sync for Log {} 27 | unsafe impl Send for Log {} 28 | 29 | // https://teratail.com/questions/244925 30 | impl Log { 31 | fn boxed(self) -> Box { 32 | Box::new(self) 33 | } 34 | 35 | pub fn boxed_new() -> Box { 36 | Self::default().boxed() 37 | } 38 | } 39 | 40 | impl Printable for Log { 41 | fn output(&self, proc_name: &str, content: &str) { 42 | let color = COLORS[self.index % COLORS.len()]; 43 | 44 | if self.opts.is_timestamp { 45 | println!( 46 | "{3} {0:1$} | {2}", 47 | proc_name.color(color), 48 | self.opts.padding, 49 | content.color(color), 50 | now().color(color) 51 | ) 52 | } else { 53 | println!( 54 | "{0:1$} | {2}", 55 | proc_name.color(color), 56 | self.opts.padding, 57 | content.color(color), 58 | ) 59 | } 60 | } 61 | 62 | fn error(&self, proc_name: &str, err: &dyn std::error::Error) { 63 | let content = &format!("error: {:?}", err); 64 | self.output(proc_name, content); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /scripts/update_homebrew.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Script to update Homebrew formula with new release 4 | set -e 5 | 6 | VERSION=$1 7 | if [ -z "$VERSION" ]; then 8 | echo "Usage: $0 " 9 | echo "Example: $0 0.3.2" 10 | exit 1 11 | fi 12 | 13 | FORMULA_REPO="yukihirop/homebrew-tap" 14 | FORMULA_FILE="Formula/ultraman.rb" 15 | 16 | # Download release files to calculate SHA256 17 | echo "Downloading release files to calculate SHA256..." 18 | MAC_URL="https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-mac.zip" 19 | LINUX_URL="https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-linux.zip" 20 | 21 | MAC_SHA256=$(curl -sL "$MAC_URL" | shasum -a 256 | cut -d' ' -f1) 22 | LINUX_SHA256=$(curl -sL "$LINUX_URL" | shasum -a 256 | cut -d' ' -f1) 23 | 24 | echo "Mac SHA256: $MAC_SHA256" 25 | echo "Linux SHA256: $LINUX_SHA256" 26 | 27 | # Create updated formula content 28 | cat > /tmp/ultraman.rb << EOF 29 | # https://qiita.com/dalance/items/b07bee6cadfd4dd19756 30 | class Ultraman < Formula 31 | version '${VERSION}' 32 | desc "Manage Procfile-based applications. (Rust Foreman)" 33 | homepage "https://github.com/yukihirop/ultraman" 34 | 35 | depends_on "rust" => :build 36 | 37 | if OS.mac? 38 | url "https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-mac.zip" 39 | sha256 '${MAC_SHA256}' 40 | end 41 | 42 | if OS.linux? 43 | url "https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-linux.zip" 44 | sha256 '${LINUX_SHA256}' 45 | end 46 | 47 | head 'https://github.com/yukihirop/ultraman.git' 48 | 49 | def install 50 | man1.install 'ultraman.1' 51 | bin.install 'ultraman' 52 | end 53 | end 54 | EOF 55 | 56 | echo "Updated formula created at /tmp/ultraman.rb" 57 | echo "Please manually update the formula in $FORMULA_REPO repository" -------------------------------------------------------------------------------- /src/opt.rs: -------------------------------------------------------------------------------- 1 | use crate::cmd::check::CheckOpts; 2 | use crate::cmd::completion::CompletionOpts; 3 | use crate::cmd::export::ExportOpts; 4 | use crate::cmd::run::RunOpts; 5 | use crate::cmd::start::StartOpts; 6 | use structopt::{clap, StructOpt}; 7 | 8 | #[derive(StructOpt, Debug)] 9 | #[structopt(long_version(option_env!("LONG_VERSION").unwrap_or(env!("CARGO_PKG_VERSION"))))] 10 | #[structopt(setting(clap::AppSettings::ColoredHelp))] 11 | pub struct Opt { 12 | #[structopt(subcommand)] 13 | pub subcommands: Option, 14 | } 15 | 16 | #[derive(StructOpt, Debug)] 17 | #[structopt( 18 | name = "ultraman", 19 | about = "Ultraman is a manager for Procfile-based applications. Its aim is to abstract away the details of the Procfile format, and allow you to either run your application directly or export it to some other process management format." 20 | )] 21 | pub enum Ultraman { 22 | #[structopt(name = "check", about = "Validate your application's Procfile")] 23 | Check(CheckOpts), 24 | 25 | #[structopt(name = "completion", about = "Generate shell completion scripts")] 26 | Completion(CompletionOpts), 27 | 28 | #[structopt(name = "start", about = "Start the application")] 29 | Start(StartOpts), 30 | 31 | #[structopt( 32 | name = "run", 33 | about = "Run a command using your application's environment" 34 | )] 35 | Run(RunOpts), 36 | 37 | #[structopt( 38 | name = "export", 39 | about = "Export the application to another process management format" 40 | )] 41 | Export(ExportOpts), 42 | } 43 | 44 | ///// Options not related to commands ///// 45 | 46 | #[derive(Clone)] 47 | pub struct DisplayOpts { 48 | pub padding: usize, 49 | pub is_timestamp: bool, 50 | } 51 | 52 | impl Default for DisplayOpts { 53 | fn default() -> Self { 54 | DisplayOpts { 55 | padding: 0, 56 | is_timestamp: true, 57 | } 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # reference: https://github.com/dalance/procs/blob/master/Makefile 2 | 3 | VERSION=$(patsubst "%",%, $(word 3, $(shell grep version Cargo.toml))) 4 | BUILD_TIME=$(shell date +"%Y/%m/%d %H:%M:%S") 5 | GIT_REVISION=$(shell git log -1 --format="%h") 6 | RUST_VERSION=$(word 2, $(shell rustc -V)) 7 | LONG_VERSION="$(VERSION) ( rev: $(GIT_REVISION), rustc: $(RUST_VERSION), build at: $(BUILD_TIME) )" 8 | BIN_NAME=ultraman 9 | BASE_RELEASE_FILES := ./tmp/ultraman.1 README.md LICENSE 10 | 11 | export LONG_VERSION 12 | 13 | .PHONY: create_man man install_man test release_linux release_win release_mac 14 | 15 | create_man: 16 | if [ ! -d ./tmp ]; then mkdir ./tmp; fi && cargo run --bin man --features man > ./tmp/ultraman.1; 17 | 18 | man: create_man 19 | man ./tmp/ultraman.1; 20 | 21 | install_man: create_man 22 | install -Dm644 ./tmp/ultraman.1 /usr/local/share/man/man1/ultraman.1; 23 | 24 | test: 25 | cargo test --locked 26 | cargo test --locked -- --ignored 27 | cargo test --locked -- --nocapture 28 | 29 | test-no-default-features: 30 | cargo test --locked --no-default-features 31 | cargo test --locked --no-default-features -- --ignored 32 | 33 | release_linux: create_man 34 | cargo build --locked --release --target=x86_64-unknown-linux-musl 35 | zip -j ${BIN_NAME}-v${VERSION}-x86_64-linux.zip target/x86_64-unknown-linux-musl/release/${BIN_NAME} $(strip $(BASE_RELEASE_FILES)) 36 | 37 | release_win: create_man 38 | cargo build --locked --release --target=x86_64-pc-windows-msvc 39 | powershell Compress-Archive -Path target/x86_64-pc-windows-msvc/release/${BIN_NAME}.exe,$(strip $(BASE_RELEASE_FILES)) -DestinationPath ${BIN_NAME}-v${VERSION}-x86_64-win.zip -Force 40 | 41 | release_mac: create_man 42 | cargo build --locked --release --target=x86_64-apple-darwin 43 | zip -j ${BIN_NAME}-v${VERSION}-x86_64-mac.zip target/x86_64-apple-darwin/release/${BIN_NAME} $(strip $(BASE_RELEASE_FILES)) 44 | 45 | release_mac_arm: create_man 46 | cargo build --locked --release --target=aarch64-apple-darwin 47 | zip -j ${BIN_NAME}-v${VERSION}-aarch64-mac.zip target/aarch64-apple-darwin/release/${BIN_NAME} $(strip $(BASE_RELEASE_FILES)) 48 | 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ultraman" 3 | version = "0.4.0" 4 | authors = ["yukihirop "] 5 | repository = "https://github.com/yukihirop/ultraman" 6 | edition = "2018" 7 | keywords= ["foreman", "multiprocess", "multithread", "pipe"] 8 | categories= ["command-line-utilities"] 9 | license= "MIT" 10 | readme = "README.md" 11 | description = "Manage Procfile-based applications" 12 | exclude = ["example/*", "man/*"] 13 | default-run="ultraman" 14 | 15 | [package.metadata.binstall] 16 | pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-{ target }.zip" 17 | pkg-fmt = "zip" 18 | 19 | [package.metadata.binstall.overrides] 20 | x86_64-apple-darwin = { pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-mac.zip" } 21 | aarch64-apple-darwin = { pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-aarch64-mac.zip" } 22 | x86_64-unknown-linux-gnu = { pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-linux.zip" } 23 | x86_64-unknown-linux-musl = { pkg-url = "{ repo }/releases/download/v{ version }/{ name }-v{ version }-x86_64-linux.zip" } 24 | 25 | # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 26 | 27 | [badges] 28 | maintenance = { status = "actively-developed" } 29 | 30 | [dependencies] 31 | chrono = "0.4.19" 32 | colored = "2.0.0" 33 | dotenv = "0.15.0" 34 | handlebars = "4.2.2" 35 | nix = "0.23.1" 36 | regex = "1.5.5" 37 | serde = "1.0.136" 38 | serde_derive = "1.0.136" 39 | serde_json = "1.0.79" 40 | shellwords = "1.1.0" 41 | signal-hook = "0.3.13" 42 | structopt = "0.3.26" 43 | # https://doc.rust-lang.org/nightly/cargo/reference/specifying-dependencies.html?highlight=git,version#multiple-locations 44 | roff = { git = "https://github.com/yukihirop/roff-rs", version = ">=0.1.0", optional = true } 45 | yaml-rust = "0.4.5" 46 | crossbeam = "0.8.1" 47 | 48 | [features] 49 | man = [ "roff" ] 50 | 51 | [profile.release] 52 | opt-level = 'z' # Optimize for size 53 | lto = true # Enable Link Time Optimization 54 | codegen-units = 1 # Reduce number of codegen units to increase optimizations 55 | panic = 'abort' # Abort on panic rather than unwinding 56 | strip = true # Strip symbols from binary 57 | 58 | [dev-dependencies] 59 | anyhow = "1.0.56" 60 | libc = "0.2.121" 61 | tempfile = "3.3.0" 62 | 63 | 64 | [[bin]] 65 | name = "ultraman" 66 | path = "src/main.rs" 67 | 68 | [[bin]] 69 | name = "man" 70 | path = "man/main.rs" 71 | required-features = [ "man" ] 72 | -------------------------------------------------------------------------------- /flake.lock: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "naersk": { 4 | "inputs": { 5 | "nixpkgs": "nixpkgs" 6 | }, 7 | "locked": { 8 | "lastModified": 1721727458, 9 | "narHash": "sha256-r/xppY958gmZ4oTfLiHN0ZGuQ+RSTijDblVgVLFi1mw=", 10 | "owner": "nix-community", 11 | "repo": "naersk", 12 | "rev": "3fb418eaf352498f6b6c30592e3beb63df42ef11", 13 | "type": "github" 14 | }, 15 | "original": { 16 | "owner": "nix-community", 17 | "ref": "master", 18 | "repo": "naersk", 19 | "type": "github" 20 | } 21 | }, 22 | "nixpkgs": { 23 | "locked": { 24 | "lastModified": 1724748588, 25 | "narHash": "sha256-NlpGA4+AIf1dKNq76ps90rxowlFXUsV9x7vK/mN37JM=", 26 | "owner": "NixOS", 27 | "repo": "nixpkgs", 28 | "rev": "a6292e34000dc93d43bccf78338770c1c5ec8a99", 29 | "type": "github" 30 | }, 31 | "original": { 32 | "id": "nixpkgs", 33 | "type": "indirect" 34 | } 35 | }, 36 | "nixpkgs_2": { 37 | "locked": { 38 | "lastModified": 1724748588, 39 | "narHash": "sha256-NlpGA4+AIf1dKNq76ps90rxowlFXUsV9x7vK/mN37JM=", 40 | "owner": "NixOS", 41 | "repo": "nixpkgs", 42 | "rev": "a6292e34000dc93d43bccf78338770c1c5ec8a99", 43 | "type": "github" 44 | }, 45 | "original": { 46 | "owner": "NixOS", 47 | "ref": "nixpkgs-unstable", 48 | "repo": "nixpkgs", 49 | "type": "github" 50 | } 51 | }, 52 | "root": { 53 | "inputs": { 54 | "naersk": "naersk", 55 | "nixpkgs": "nixpkgs_2", 56 | "utils": "utils" 57 | } 58 | }, 59 | "systems": { 60 | "locked": { 61 | "lastModified": 1681028828, 62 | "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", 63 | "owner": "nix-systems", 64 | "repo": "default", 65 | "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", 66 | "type": "github" 67 | }, 68 | "original": { 69 | "owner": "nix-systems", 70 | "repo": "default", 71 | "type": "github" 72 | } 73 | }, 74 | "utils": { 75 | "inputs": { 76 | "systems": "systems" 77 | }, 78 | "locked": { 79 | "lastModified": 1710146030, 80 | "narHash": "sha256-SZ5L6eA7HJ/nmkzGG7/ISclqe6oZdOZTNoesiInkXPQ=", 81 | "owner": "numtide", 82 | "repo": "flake-utils", 83 | "rev": "b1d9ab70662946ef0850d488da1c9019f3a9752a", 84 | "type": "github" 85 | }, 86 | "original": { 87 | "owner": "numtide", 88 | "repo": "flake-utils", 89 | "type": "github" 90 | } 91 | } 92 | }, 93 | "root": "root", 94 | "version": 7 95 | } 96 | -------------------------------------------------------------------------------- /.github/workflows/update-homebrew.yml: -------------------------------------------------------------------------------- 1 | name: Update Homebrew Formula 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | update-homebrew: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout 12 | uses: actions/checkout@v4 13 | 14 | - name: Extract version from tag 15 | id: version 16 | run: | 17 | TAG=${GITHUB_REF#refs/tags/v} 18 | echo "version=$TAG" >> $GITHUB_OUTPUT 19 | 20 | - name: Update Homebrew Formula 21 | env: 22 | HOMEBREW_TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }} 23 | VERSION: ${{ steps.version.outputs.version }} 24 | run: | 25 | # Wait for release assets to be available 26 | sleep 30 27 | 28 | # Download release files to calculate SHA256 29 | MAC_URL="https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-mac.zip" 30 | LINUX_URL="https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-linux.zip" 31 | 32 | echo "Downloading and calculating SHA256 for Mac..." 33 | MAC_SHA256=$(curl -sL "$MAC_URL" | shasum -a 256 | cut -d' ' -f1) 34 | 35 | echo "Downloading and calculating SHA256 for Linux..." 36 | LINUX_SHA256=$(curl -sL "$LINUX_URL" | shasum -a 256 | cut -d' ' -f1) 37 | 38 | echo "Mac SHA256: $MAC_SHA256" 39 | echo "Linux SHA256: $LINUX_SHA256" 40 | 41 | # Clone homebrew tap repository 42 | git clone https://x-access-token:${HOMEBREW_TAP_TOKEN}@github.com/yukihirop/homebrew-tap.git 43 | cd homebrew-tap 44 | 45 | # Update formula 46 | cat > Formula/ultraman.rb << EOF 47 | # https://qiita.com/dalance/items/b07bee6cadfd4dd19756 48 | class Ultraman < Formula 49 | version '${VERSION}' 50 | desc "Manage Procfile-based applications. (Rust Foreman)" 51 | homepage "https://github.com/yukihirop/ultraman" 52 | 53 | depends_on "rust" => :build 54 | 55 | if OS.mac? 56 | url "https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-mac.zip" 57 | sha256 '${MAC_SHA256}' 58 | end 59 | 60 | if OS.linux? 61 | url "https://github.com/yukihirop/ultraman/releases/download/v${VERSION}/ultraman-v${VERSION}-x86_64-linux.zip" 62 | sha256 '${LINUX_SHA256}' 63 | end 64 | 65 | head 'https://github.com/yukihirop/ultraman.git' 66 | 67 | def install 68 | man1.install 'ultraman.1' 69 | bin.install 'ultraman' 70 | end 71 | end 72 | EOF 73 | 74 | # Commit and push changes 75 | git config user.name "GitHub Actions" 76 | git config user.email "actions@github.com" 77 | git add Formula/ultraman.rb 78 | git commit -m "Update ultraman to v${VERSION}" 79 | git push origin main -------------------------------------------------------------------------------- /src/log/mod.rs: -------------------------------------------------------------------------------- 1 | use crate::opt::DisplayOpts; 2 | use chrono::Local; 3 | 4 | pub mod color; 5 | pub mod plain; 6 | 7 | pub trait Printable { 8 | fn output(&self, proc_name: &str, content: &str); 9 | fn error(&self, proc_name: &str, err: &dyn std::error::Error); 10 | } 11 | 12 | pub struct Log; 13 | 14 | #[derive(Clone)] 15 | pub struct LogOpt { 16 | pub is_color: bool, 17 | pub padding: usize, 18 | pub is_timestamp: bool, 19 | } 20 | 21 | impl Log { 22 | pub fn new(index: usize, opt: &LogOpt) -> Box { 23 | if opt.is_color { 24 | let mut color = color::Log::boxed_new(); 25 | color.index = index; 26 | color.opts = Self::display_opts(opt); 27 | color 28 | } else { 29 | let mut plain = plain::Log::boxed_new(); 30 | plain.index = index; 31 | plain.opts = Self::display_opts(opt); 32 | plain 33 | } 34 | } 35 | 36 | fn display_opts(opt: &LogOpt) -> DisplayOpts { 37 | DisplayOpts { 38 | padding: opt.padding, 39 | is_timestamp: opt.is_timestamp, 40 | } 41 | } 42 | } 43 | 44 | pub fn output(proc_name: &str, content: &str, index: Option, opt: &LogOpt) { 45 | let index = index.unwrap_or_else(|| 0); 46 | let log = Log::new(index, opt); 47 | log.output(proc_name, content) 48 | } 49 | 50 | pub fn error(proc_name: &str, err: &dyn std::error::Error, is_padding: bool, opt: &LogOpt) { 51 | let content = &format!("error: {:?}", err); 52 | if is_padding { 53 | output(proc_name, content, None, &opt); 54 | } else { 55 | let remake_opt = LogOpt { 56 | is_color: opt.is_color, 57 | padding: proc_name.len() + 1, 58 | is_timestamp: opt.is_timestamp, 59 | }; 60 | output(proc_name, content, None, &remake_opt); 61 | } 62 | } 63 | 64 | pub fn now() -> String { 65 | Local::now().format("%H:%M:%S").to_string() 66 | } 67 | 68 | #[cfg(test)] 69 | mod tests { 70 | use super::*; 71 | use anyhow; 72 | use std::error::Error; 73 | use std::fmt; 74 | 75 | #[test] 76 | fn test_output_when_coloring() -> anyhow::Result<()> { 77 | let log = Log::new( 78 | 0, 79 | &LogOpt { 80 | is_color: true, 81 | padding: 10, 82 | is_timestamp: true, 83 | }, 84 | ); 85 | log.output("output 1", "coloring"); 86 | 87 | Ok(()) 88 | } 89 | 90 | #[test] 91 | fn test_output_when_not_coloring() -> anyhow::Result<()> { 92 | let log = Log::new( 93 | 0, 94 | &LogOpt { 95 | is_color: false, 96 | padding: 10, 97 | is_timestamp: true, 98 | }, 99 | ); 100 | log.output("output 1", "not coloring"); 101 | 102 | Ok(()) 103 | } 104 | 105 | // https://www.366service.com/jp/qa/265b4c8f485bfeedef32947292211f12 106 | #[derive(Debug)] 107 | struct TestError<'a>(&'a str); 108 | impl<'a> Error for TestError<'a> {} 109 | impl<'a> fmt::Display for TestError<'a> { 110 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 111 | self.0.fmt(f) 112 | } 113 | } 114 | 115 | #[test] 116 | fn test_error() -> anyhow::Result<()> { 117 | let error = TestError("test error"); 118 | let log = Log::new( 119 | 0, 120 | &LogOpt { 121 | is_color: true, 122 | padding: 10, 123 | is_timestamp: true, 124 | }, 125 | ); 126 | log.error("test_app", &error); 127 | 128 | Ok(()) 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/cmd/check.rs: -------------------------------------------------------------------------------- 1 | use crate::config::{read_config, Config}; 2 | use crate::procfile::read_procfile; 3 | 4 | use std::path::PathBuf; 5 | use structopt::{clap, StructOpt}; 6 | 7 | #[cfg(not(test))] 8 | use std::process::exit; 9 | 10 | #[derive(StructOpt, Debug)] 11 | #[structopt(setting(clap::AppSettings::ColoredHelp))] 12 | pub struct CheckOpts { 13 | /// Specify an Procfile to load 14 | #[structopt(name = "PROCFILE", short = "f", long = "procfile", parse(from_os_str))] 15 | pub procfile_path: Option, 16 | } 17 | 18 | pub fn run(input_opts: CheckOpts) { 19 | let dotconfig = read_config(PathBuf::from("./ultraman")).unwrap(); 20 | let opts = merged_opts(&input_opts, dotconfig); 21 | 22 | let procfile_path = opts.procfile_path.unwrap(); 23 | if !&procfile_path.exists() { 24 | let display_path = procfile_path.into_os_string().into_string().unwrap(); 25 | eprintln!("{} does not exist.", &display_path); 26 | // https://www.reddit.com/r/rust/comments/emz456/testing_whether_functions_exit/ 27 | #[cfg(not(test))] 28 | exit(1); 29 | #[cfg(test)] 30 | panic!("exit {}", 1); 31 | } 32 | let procfile = read_procfile(procfile_path).expect("failed read Procfile"); 33 | 34 | if !procfile.check() { 35 | eprintln!("no process defined"); 36 | } else { 37 | println!("valid procfile detected ({})", procfile.process_names()); 38 | } 39 | } 40 | 41 | fn merged_opts(input_opts: &CheckOpts, dotconfig: Config) -> CheckOpts { 42 | CheckOpts { 43 | procfile_path: match &input_opts.procfile_path { 44 | Some(r) => Some(PathBuf::from(r)), 45 | None => Some(dotconfig.procfile_path), 46 | }, 47 | } 48 | } 49 | 50 | #[cfg(test)] 51 | mod tests { 52 | use super::*; 53 | use std::fs::File; 54 | use std::io::Write; 55 | use tempfile::tempdir; 56 | 57 | fn prepare_dotconfig() -> Config { 58 | let dir = tempdir().ok().unwrap(); 59 | let file_path = dir.path().join(".ultraman"); 60 | let mut file = File::create(file_path.clone()).ok().unwrap(); 61 | // Writing a comment causes a parse error 62 | writeln!( 63 | file, 64 | r#" 65 | procfile: ./tmp/Procfile 66 | env: ./tmp/.env 67 | 68 | formation: app=1,web=2 69 | port: 6000 70 | timeout: 5000 71 | 72 | no-timestamp: true 73 | 74 | app: app-for-runit 75 | log: /var/app/log/ultraman.log 76 | run: /tmp/pids/ultraman.pid 77 | template: ../../src/cmd/export/templates/supervisord 78 | user: root 79 | root: /home/app 80 | 81 | hoge: hogehoge 82 | "# 83 | ) 84 | .unwrap(); 85 | 86 | let dotconfig = read_config(file_path).expect("failed read .ultraman"); 87 | dotconfig 88 | } 89 | 90 | #[test] 91 | fn test_merged_opts_when_prefer_dotconfig() -> anyhow::Result<()> { 92 | let input_opts = CheckOpts { 93 | procfile_path: None, 94 | }; 95 | 96 | let dotconfig = prepare_dotconfig(); 97 | let result = merged_opts(&input_opts, dotconfig); 98 | 99 | assert_eq!( 100 | result.procfile_path.unwrap(), 101 | PathBuf::from("./tmp/Procfile") 102 | ); 103 | 104 | Ok(()) 105 | } 106 | 107 | #[test] 108 | fn test_merged_opts_when_prefer_input_opts() -> anyhow::Result<()> { 109 | let input_opts = CheckOpts { 110 | procfile_path: Some(PathBuf::from("./test/Procfile")), 111 | }; 112 | 113 | let dotconfig = prepare_dotconfig(); 114 | let result = merged_opts(&input_opts, dotconfig); 115 | 116 | assert_eq!( 117 | result.procfile_path.unwrap(), 118 | PathBuf::from("./test/Procfile") 119 | ); 120 | 121 | Ok(()) 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /CLAUDE.md: -------------------------------------------------------------------------------- 1 | # CLAUDE.md 2 | 3 | This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. 4 | 5 | ## Project Overview 6 | 7 | Ultraman is a Rust implementation of Foreman - a tool for managing Procfile-based applications. It allows you to run applications defined in a Procfile, export them to various process management formats (systemd, launchd, supervisord, etc.), and manage multi-process applications with proper signal handling. 8 | 9 | ## Key Architecture 10 | 11 | - **Command Structure**: Four main commands implemented in `src/cmd/`: 12 | - `start`: Run processes defined in Procfile 13 | - `run`: Execute single command with app environment 14 | - `check`: Validate Procfile syntax 15 | - `export`: Generate config files for process managers 16 | 17 | - **Core Components**: 18 | - `src/procfile.rs`: Parses Procfile format using regex patterns 19 | - `src/process.rs`: Manages child processes with proper signal handling 20 | - `src/signal.rs`: Handles SIGINT/SIGTERM for graceful shutdown 21 | - `src/log/`: Output formatting with timestamps and colors 22 | - `src/cmd/export/`: Contains exporters for different process management systems 23 | 24 | - **Process Management**: Each process gets unique PORT assignments and PS names. Signal handling ensures proper cleanup of child processes on shutdown. 25 | 26 | ## Development Commands 27 | 28 | ```bash 29 | # Build and run 30 | cargo run start # Start with default Procfile 31 | cargo run start -p ./Procfile # Start with specific Procfile 32 | cargo run -- --help # Show help 33 | cargo run start --help # Show start command help 34 | 35 | # Testing 36 | cargo test --locked # Run standard tests 37 | cargo test --locked -- --ignored # Run signal handling tests (can interrupt other tests) 38 | cargo test --locked -- --nocapture # Run with output 39 | 40 | # Development shortcuts from README 41 | cargo run export 42 | cargo run run 43 | ``` 44 | 45 | ## Build and Release 46 | 47 | ```bash 48 | # Development build 49 | cargo build 50 | 51 | # Testing with make 52 | make test # Full test suite including ignored tests 53 | 54 | # Man page generation 55 | make man # View man page 56 | make create_man # Generate man page to ./tmp/ultraman.1 57 | make install_man # Install man page system-wide 58 | 59 | # Cross-platform releases 60 | make release_linux # Build for x86_64-unknown-linux-musl 61 | make release_mac # Build for x86_64-apple-darwin 62 | make release_win # Build for x86_64-pc-windows-msvc 63 | ``` 64 | 65 | ## Testing Notes 66 | 67 | - Signal handling tests in `src/signal.rs` are marked with `#[ignore]` as they can interrupt other tests 68 | - Use `cargo test -- --ignored` to run signal tests specifically 69 | - Docker environment available for Linux testing on macOS: `docker-compose up -d` 70 | 71 | ## Release Process 72 | 73 | Ultraman uses automated GitHub Actions to build and release pre-compiled binaries: 74 | 75 | 1. **Automated Releases**: When a new version tag (e.g., `v0.3.3`) is pushed, GitHub Actions automatically: 76 | - Builds binaries for Linux (x86_64), macOS (x86_64 and ARM64), and Windows (x86_64) 77 | - Uploads the binaries to GitHub Releases 78 | - Automatically updates the Homebrew formula with new version and SHA256 hashes 79 | 80 | 2. **Manual Release**: Create and push a new tag: 81 | ```bash 82 | git tag v0.3.3 83 | git push origin v0.3.3 84 | ``` 85 | 86 | 3. **Pre-built Binaries**: Users can now install without compilation: 87 | - **Homebrew**: `brew install yukihirop/tap/ultraman` (uses pre-built binaries) 88 | - **Direct Download**: Download from GitHub Releases page 89 | - **Cargo**: `cargo install ultraman` (still compiles from source) 90 | 91 | ## Key Dependencies 92 | 93 | - `structopt`: CLI argument parsing 94 | - `nix`: Unix signal handling and process management 95 | - `handlebars`: Template engine for export formats 96 | - `signal-hook`: Signal handling utilities 97 | - `crossbeam`: Thread coordination -------------------------------------------------------------------------------- /example/start/README.md: -------------------------------------------------------------------------------- 1 | # Ultraman start example 2 | 3 | If no additional parameters are passed, `ultraman` will run one instance of each type of process defined in your `Procfile`. 4 | 5 | The following options control how the application is run: 6 | 7 | |short|long|default|description| 8 | |-----|----|-------|-----------| 9 | |-m|--formation|`all=1`|Specify the number of each process type to run. The value passed in should be in the format process=num,process=num| 10 | |-e|--env|`.env`|Specify an environment file to load| 11 | |-f|--procfile|`Procfile`|Specify an alternate Procfile to load| 12 | |-p|--port||Specify which port to use as the base for this application. Should be a multiple of 1000| 13 | |-t|--timeout|`5`|Specify the amount of time (in seconds) processes have to shutdown gracefully before receiving a SIGTERM| 14 | |-n|--no-timestamp|`false`|Include timestamp in output| 15 | 16 | ## Example 17 | 18 | Here is an example when the `Procfile` and `.env` files have the following contents 19 | 20 | [Procfile] 21 | ``` 22 | exit_0: ./fixtures/exit_0.sh 23 | exit_1: ./fixtures/exit_1.sh 24 | loop: ./fixtures/loop.sh $MESSAGE 25 | ``` 26 | 27 | [.env] 28 | ``` 29 | MESSAGE="Hello World" 30 | ``` 31 | 32 | ### Full option example (short) 33 | 34 | ```bash 35 | cargo run start \ 36 | -m loop=2,exit_1=3 \ 37 | -e ./.env \ 38 | -f ./Procfile \ 39 | -p 7000 \ 40 | -t 10 \ 41 | -n false 42 | ``` 43 | 44 |
45 | 46 | ```bash 47 | system | exit_1.3 start at pid: 64568 48 | system | exit_1.2 start at pid: 64569 49 | system | exit_1.1 start at pid: 64570 50 | system | loop.1 start at pid: 64571 51 | system | loop.2 start at pid: 64572 52 | loop.2 | Hello World 53 | loop.1 | Hello World 54 | exit_1.1 | failed 55 | exit_1.3 | failed 56 | exit_1.2 | failed 57 | exit_1.1 | exited with code 1 58 | system | sending SIGTERM for exit_1.3 at pid 64568 59 | system | sending SIGTERM for exit_1.2 at pid 64569 60 | system | sending SIGTERM for loop.1 at pid 64571 61 | system | sending SIGTERM for loop.2 at pid 64572 62 | exit_1.2 | exited with code 1 63 | system | sending SIGTERM for exit_1.3 at pid 64568 64 | system | sending SIGTERM for loop.1 at pid 64571 65 | system | sending SIGTERM for loop.2 at pid 64572 66 | exit_1.3 | exited with code 1 67 | system | sending SIGTERM for loop.1 at pid 64571 68 | system | sending SIGTERM for loop.2 at pid 64572 69 | loop.1 | terminated by SIGTERM 70 | loop.2 | terminated by SIGTERM 71 | ``` 72 | 73 |
74 | 75 | ### Full option example (long) 76 | 77 | ```bash 78 | cargo run start \ 79 | --formation all=2 \ 80 | --env ./.env \ 81 | --procfile ./Procfile \ 82 | --port 7000 \ 83 | --timeout 10 \ 84 | --no-timestamp false 85 | ``` 86 | 87 |
88 | 89 | ```bash 90 | system | exit_1.1 start at pid: 65179 91 | system | exit_0.2 start at pid: 65180 92 | system | loop.2 start at pid: 65181 93 | system | exit_0.1 start at pid: 65182 94 | system | loop.1 start at pid: 65183 95 | system | exit_1.2 start at pid: 65184 96 | loop.1 | Hello World 97 | loop.2 | Hello World 98 | exit_1.2 | failed 99 | exit_1.1 | failed 100 | exit_1.1 | exited with code 1 101 | system | sending SIGTERM for exit_0.2 at pid 65180 102 | system | sending SIGTERM for loop.2 at pid 65181 103 | system | sending SIGTERM for exit_0.1 at pid 65182 104 | system | sending SIGTERM for loop.1 at pid 65183 105 | system | sending SIGTERM for exit_1.2 at pid 65184 106 | exit_1.2 | exited with code 1 107 | system | sending SIGTERM for exit_0.2 at pid 65180 108 | system | sending SIGTERM for loop.2 at pid 65181 109 | system | sending SIGTERM for exit_0.1 at pid 65182 110 | system | sending SIGTERM for loop.1 at pid 65183 111 | exit_0.1 | terminated by SIGTERM 112 | loop.1 | terminated by SIGTERM 113 | loop.2 | terminated by SIGTERM 114 | exit_0.2 | terminated by SIGTERM 115 | ``` 116 | 117 |
118 | -------------------------------------------------------------------------------- /src/output.rs: -------------------------------------------------------------------------------- 1 | use crate::log::{Log, LogOpt, Printable}; 2 | use crate::opt::DisplayOpts; 3 | use crate::process::Process; 4 | use crate::stream_read::{PipeError, PipeStreamReader, PipedLine}; 5 | 6 | use crossbeam::channel::Select; 7 | use std::sync::{Arc, Mutex}; 8 | 9 | pub struct Output { 10 | pub log: Box, 11 | } 12 | 13 | impl Output { 14 | pub fn new(index: usize, opts: DisplayOpts) -> Self { 15 | Output { 16 | log: Log::new( 17 | index, 18 | &LogOpt { 19 | is_color: true, 20 | padding: opts.padding, 21 | is_timestamp: opts.is_timestamp, 22 | }, 23 | ), 24 | } 25 | } 26 | 27 | pub fn handle_output(&self, proc: &Arc>) { 28 | let mut channels: Vec = Vec::new(); 29 | channels.push(PipeStreamReader::new(Box::new( 30 | proc.lock() 31 | .unwrap() 32 | .child 33 | .stdout 34 | .take() 35 | .expect("failed take stdout"), 36 | ))); 37 | channels.push(PipeStreamReader::new(Box::new( 38 | proc.lock() 39 | .unwrap() 40 | .child 41 | .stderr 42 | .take() 43 | .expect("failed take stderr"), 44 | ))); 45 | 46 | let mut select = Select::new(); 47 | for channel in channels.iter() { 48 | select.recv(&channel.lines); 49 | } 50 | 51 | let mut stream_eof = false; 52 | 53 | while !stream_eof { 54 | let operation = select.select(); 55 | let index = operation.index(); 56 | let received = operation.recv( 57 | &channels 58 | .get(index) 59 | .expect("failed get channel at index") 60 | .lines, 61 | ); 62 | let log = &self.log; 63 | 64 | match received { 65 | Ok(remote_result) => match remote_result { 66 | Ok(piped_line) => match piped_line { 67 | PipedLine::Line(line) => { 68 | log.output(&proc.lock().unwrap().name, &line); 69 | } 70 | PipedLine::EOF => { 71 | stream_eof = true; 72 | select.remove(index); 73 | } 74 | }, 75 | Err(error) => match error { 76 | PipeError::IO(err) => log.error(&proc.lock().unwrap().name, &err), 77 | PipeError::NotUtf8(err) => log.error(&proc.lock().unwrap().name, &err), 78 | }, 79 | }, 80 | Err(_) => { 81 | stream_eof = true; 82 | select.remove(index); 83 | } 84 | } 85 | } 86 | 87 | // MEMO 88 | // 89 | // An error occurs in a child process that was terminated by sending a SIGTERM 90 | // It is necessary to be able to send a signal after successfully executing the termination process. 91 | // 92 | 93 | // blocking 94 | // let status = proc.lock().unwrap().child.wait().expect("!wait"); 95 | // let proc_name = &proc.lock().unwrap().name; 96 | // if status.success() { 97 | // log::output(proc_name, "onsucceed handler"); 98 | // } else { 99 | // log::output(proc_name, "onfailed handler"); 100 | // } 101 | } 102 | } 103 | 104 | #[cfg(test)] 105 | mod tests { 106 | use super::*; 107 | use anyhow; 108 | use std::process::{Command, Stdio}; 109 | 110 | #[test] 111 | fn test_handle_output() -> anyhow::Result<()> { 112 | let proc = Arc::new(Mutex::new(Process { 113 | index: 0, 114 | name: String::from("handle_output"), 115 | child: Command::new("./test/fixtures/for.sh") 116 | .stdout(Stdio::piped()) 117 | .stderr(Stdio::piped()) 118 | .spawn() 119 | .expect("failed execute handle_output command"), 120 | opts: None, 121 | })); 122 | 123 | let proc2 = Arc::clone(&proc); 124 | let output = Output::new( 125 | 0, 126 | DisplayOpts { 127 | padding: 10, 128 | is_timestamp: true, 129 | }, 130 | ); 131 | output.handle_output(&proc2); 132 | 133 | Ok(()) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/cmd/export/base.rs: -------------------------------------------------------------------------------- 1 | use crate::cmd::export::ExportOpts; 2 | use crate::env::read_env; 3 | 4 | use handlebars::Handlebars; 5 | use serde_derive::Serialize; 6 | use serde_json::value::{Map, Value as Json}; 7 | use std::env; 8 | use std::fs::File; 9 | use std::fs::{create_dir_all, remove_file}; 10 | use std::io::Read; 11 | use std::path::PathBuf; 12 | 13 | // Lifetime cannot be set because it will be HashMap data with anonymous runtime 14 | #[derive(Serialize)] 15 | pub struct EnvParameter { 16 | pub(crate) key: String, 17 | pub(crate) value: String, 18 | } 19 | 20 | pub struct Template { 21 | pub(crate) template_path: PathBuf, 22 | pub(crate) data: Map, 23 | pub(crate) output_path: PathBuf, 24 | } 25 | 26 | pub trait Exportable { 27 | fn export(&self) -> Result<(), Box>; 28 | //https://yajamon.hatenablog.com/entry/2018/01/30/202849 29 | fn ref_opts(&self) -> &ExportOpts; 30 | 31 | fn base_export(&self) -> Result<(), Box> { 32 | let opts = self.ref_opts(); 33 | let location = &opts.location; 34 | let display = location.clone().into_os_string().into_string().unwrap(); 35 | create_dir_all(&location).expect(&format!("Could not create: {}", display)); 36 | 37 | // self.chown(&username, &self.log_path()); 38 | // self.chown(&username, &self.run_path()); 39 | Ok(()) 40 | } 41 | 42 | fn app(&self) -> &str { 43 | self.ref_opts().app.as_deref().unwrap_or_else(|| "app") 44 | } 45 | 46 | fn log_path(&self) -> PathBuf { 47 | self.ref_opts() 48 | .log_path 49 | .clone() 50 | .unwrap_or_else(|| PathBuf::from(format!("/var/log/{}", self.app()))) 51 | } 52 | 53 | fn run_path(&self) -> PathBuf { 54 | self.ref_opts() 55 | .run_path 56 | .clone() 57 | .unwrap_or_else(|| PathBuf::from(format!("/var/run/{}", self.app()))) 58 | } 59 | 60 | fn username(&self) -> &str { 61 | self.ref_opts() 62 | .user 63 | .as_deref() 64 | .unwrap_or_else(|| self.app()) 65 | } 66 | 67 | fn root_path(&self) -> PathBuf { 68 | self.ref_opts() 69 | .root_path 70 | .clone() 71 | .unwrap_or_else(|| env::current_dir().unwrap()) 72 | } 73 | 74 | fn clean(&self, filepath: &PathBuf) { 75 | let display = filepath.clone().into_os_string().into_string().unwrap(); 76 | if filepath.exists() { 77 | self.say(&format!("cleaning: {}", display)); 78 | remove_file(filepath).expect(&format!("Could not remove file: {}", display)); 79 | } 80 | } 81 | 82 | fn project_root_path(&self) -> PathBuf { 83 | PathBuf::from(env!("CARGO_MANIFEST_DIR")) 84 | } 85 | 86 | fn say(&self, msg: &str) { 87 | println!("[ultraman export] {}", msg) 88 | } 89 | 90 | fn write_template(&self, tmpl: Template) { 91 | let handlebars = Handlebars::new(); 92 | let display_template = tmpl 93 | .template_path 94 | .clone() 95 | .into_os_string() 96 | .into_string() 97 | .unwrap(); 98 | let display_output = tmpl 99 | .output_path 100 | .clone() 101 | .into_os_string() 102 | .into_string() 103 | .unwrap(); 104 | let mut output_file = File::create(tmpl.output_path) 105 | .expect(&format!("Could not create file: {}", &display_output)); 106 | self.say(&format!("writing: {}", &display_output)); 107 | let mut data = tmpl.data; 108 | let mut template_source = File::open(tmpl.template_path) 109 | .expect(&format!("Could not open file: {}", display_template)); 110 | let mut template_str = String::new(); 111 | template_source 112 | .read_to_string(&mut template_str) 113 | .expect(&format!("Could not read file: {}", display_template)); 114 | handlebars 115 | .render_template_to_write(&mut template_str, &mut data, &mut output_file) 116 | .expect(&format!("Coult not render file: {}", &display_output)); 117 | } 118 | 119 | fn output_path(&self, filename: &str) -> PathBuf { 120 | let location = self.ref_opts().location.clone(); 121 | location.join(filename) 122 | } 123 | 124 | fn env_without_port(&self) -> Vec { 125 | let mut env = 126 | read_env(self.ref_opts().env_path.clone().unwrap()).expect("failed read .env"); 127 | env.remove("PORT"); 128 | let mut env_without_port: Vec = vec![]; 129 | for (key, value) in env { 130 | env_without_port.push(EnvParameter { key, value }); 131 | } 132 | env_without_port 133 | } 134 | 135 | fn create_dir_recursive(&self, dir_path: &PathBuf) { 136 | let display = dir_path.clone().into_os_string().into_string().unwrap(); 137 | create_dir_all(dir_path).expect(&format!("Could not create: {}", display)) 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /src/cmd/export/launchd.rs: -------------------------------------------------------------------------------- 1 | use super::base::{EnvParameter, Exportable, Template}; 2 | use crate::cmd::export::ExportOpts; 3 | use crate::env::read_env; 4 | use crate::process::port_for; 5 | use crate::procfile::{Procfile, ProcfileEntry}; 6 | use handlebars::to_json; 7 | use serde_derive::Serialize; 8 | use serde_json::value::{Map, Value as Json}; 9 | use std::collections::HashMap; 10 | use std::env; 11 | use std::marker::PhantomData; 12 | use std::path::PathBuf; 13 | 14 | pub struct Exporter<'a> { 15 | pub procfile: Procfile, 16 | pub opts: ExportOpts, 17 | _marker: PhantomData<&'a ()>, 18 | } 19 | 20 | #[derive(Serialize)] 21 | struct LaunchdParams<'a> { 22 | label: &'a str, 23 | env: Vec, 24 | command_args: Vec<&'a str>, 25 | stdout_path: &'a str, 26 | stderr_path: &'a str, 27 | user: &'a str, 28 | work_dir: &'a str, 29 | } 30 | 31 | impl<'a> Default for Exporter<'a> { 32 | fn default() -> Self { 33 | Exporter { 34 | procfile: Procfile { 35 | data: HashMap::new(), 36 | }, 37 | opts: ExportOpts { 38 | format: String::from(""), 39 | location: PathBuf::from("location"), 40 | app: None, 41 | formation: Some(String::from("all=1")), 42 | log_path: None, 43 | run_path: None, 44 | port: None, 45 | template_path: None, 46 | user: None, 47 | env_path: Some(PathBuf::from(".env")), 48 | procfile_path: Some(PathBuf::from("Procfile")), 49 | root_path: Some(env::current_dir().unwrap()), 50 | timeout: Some(5), 51 | }, 52 | _marker: PhantomData, 53 | } 54 | } 55 | } 56 | 57 | impl<'a> Exporter<'a> { 58 | fn boxed(self) -> Box { 59 | Box::new(self) 60 | } 61 | 62 | pub fn boxed_new() -> Box { 63 | Self::default().boxed() 64 | } 65 | 66 | fn launchd_tmpl_path(&self) -> PathBuf { 67 | let mut path = self.project_root_path(); 68 | let tmpl_path = PathBuf::from("src/cmd/export/templates/launchd/launchd.plist.hbs"); 69 | path.push(tmpl_path); 70 | path 71 | } 72 | 73 | fn make_launchd_data( 74 | &self, 75 | pe: &ProcfileEntry, 76 | service_name: &str, 77 | con_index: usize, 78 | ) -> Map { 79 | let mut data = Map::new(); 80 | let log_display = self.log_path().into_os_string().into_string().unwrap(); 81 | let lp = LaunchdParams { 82 | label: service_name, 83 | env: self.environment(con_index), 84 | command_args: self.command_args(pe), 85 | stdout_path: &format!("{}/{}.log", &log_display, &service_name), 86 | stderr_path: &format!("{}/{}.error.log", &log_display, &service_name), 87 | user: self.username(), 88 | work_dir: &self.root_path().into_os_string().into_string().unwrap(), 89 | }; 90 | data.insert("launchd".to_string(), to_json(&lp)); 91 | data 92 | } 93 | 94 | fn command_args(&self, pe: &'a ProcfileEntry) -> Vec<&'a str> { 95 | let data = pe.command.split(" ").collect::>(); 96 | let mut result: Vec<&'a str> = vec![]; 97 | for item in data { 98 | result.push(item) 99 | } 100 | result 101 | } 102 | 103 | fn environment(&self, con_index: usize) -> Vec { 104 | let port = port_for( 105 | &self.opts.env_path.clone().unwrap(), 106 | self.opts.port.clone(), 107 | con_index, 108 | ); 109 | let mut env = read_env(self.opts.env_path.clone().unwrap()).expect("failed read .env"); 110 | env.insert("PORT".to_string(), port.to_string()); 111 | 112 | let mut result = vec![]; 113 | for (key, val) in env.iter() { 114 | result.push(EnvParameter { 115 | key: key.to_string(), 116 | value: val.to_string(), 117 | }); 118 | } 119 | 120 | result 121 | } 122 | } 123 | 124 | impl<'a> Exportable for Exporter<'a> { 125 | fn export(&self) -> Result<(), Box> { 126 | self.base_export().expect("failed execute base_export"); 127 | 128 | let mut clean_paths: Vec = vec![]; 129 | let mut tmpl_data: Vec