├── .ameba.yml ├── .github └── workflows │ ├── alpine_x86_64_release.yml │ ├── ci.yml │ ├── gnu_x86_64_release.yml │ └── macos_release.yml ├── .gitignore ├── LICENSE ├── Makefile ├── README.md ├── scripts ├── bar.sh ├── foo.sh └── test.sh ├── shard.lock ├── shard.yml ├── spec ├── apps │ ├── basic │ │ ├── Procfile │ │ └── process.rb │ ├── full │ │ ├── Procfile │ │ ├── Procfile.options │ │ └── process.rb │ └── slow │ │ ├── Procfile │ │ ├── Procfile.options │ │ └── process.rb ├── spec_helper.cr └── specs │ ├── app_determination_spec.cr │ ├── cli_spec.cr │ ├── config_spec.cr │ ├── process_spec.cr │ └── procfile_option_spec.cr ├── src ├── procodile.cr ├── procodile │ ├── app_determination.cr │ ├── cli.cr │ ├── commands │ │ ├── check_concurrency_command.cr │ │ ├── console_command.cr │ │ ├── exec_command.cr │ │ ├── help_command.cr │ │ ├── kill_command.cr │ │ ├── log_command.cr │ │ ├── restart_command.cr │ │ ├── run_command.cr │ │ ├── start_command.cr │ │ ├── status_command.cr │ │ └── stop_command.cr │ ├── config.cr │ ├── control_client.cr │ ├── control_server.cr │ ├── control_session.cr │ ├── core_ext │ │ └── process.cr │ ├── instance.cr │ ├── logger.cr │ ├── process.cr │ ├── signal_handler.cr │ ├── start_supervisor.cr │ └── supervisor.cr └── requires.cr └── test-app ├── Procfile ├── Procfile.local ├── Procfile.options ├── cron.rb ├── global_config.yml ├── web.rb └── worker.rb /.ameba.yml: -------------------------------------------------------------------------------- 1 | # This configuration file was generated by `ameba --gen-config` 2 | # on 2023-02-20 03:52:25 UTC using Ameba version 1.3.1. 3 | # The point is for the user to remove these configuration records 4 | # one by one as the reported problems are removed from the code base. 5 | 6 | # Problems found: 9 7 | # Run `ameba --only Lint/NotNil` for details 8 | Lint/NotNil: 9 | Description: Identifies usage of `not_nil!` calls 10 | Enabled: false 11 | Severity: Warning 12 | 13 | # Problems found: 7 14 | # Run `ameba --only Style/ParenthesesAroundCondition` for details 15 | Style/ParenthesesAroundCondition: 16 | Description: Disallows redundant parentheses around control expressions 17 | ExcludeTernary: false 18 | AllowSafeAssignment: true 19 | Enabled: true 20 | Severity: Convention 21 | 22 | # Problems found: 3 23 | # Run `ameba --only Metrics/CyclomaticComplexity` for details 24 | Metrics/CyclomaticComplexity: 25 | Description: Disallows methods with a cyclomatic complexity higher than `MaxComplexity` 26 | MaxComplexity: 10 27 | Enabled: false 28 | Severity: Warning 29 | 30 | Naming/BlockParameterName: 31 | Enabled: false 32 | 33 | Naming/AccessorMethodName: 34 | Enabled: false 35 | -------------------------------------------------------------------------------- /.github/workflows/alpine_x86_64_release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*.*.*" 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | container: 10 | image: crystallang/crystal:latest-alpine 11 | steps: 12 | - name: Cache shards 13 | uses: actions/cache@v2 14 | with: 15 | path: ~/.cache/shards 16 | key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} 17 | restore-keys: ${{ runner.os }}-shards- 18 | 19 | - name: Download source 20 | uses: actions/checkout@v4 21 | 22 | - name: Install shards 23 | run: shards check || shards install --without-development 24 | 25 | - name: Check formatting 26 | run: crystal tool format --check 27 | 28 | - name: Run tests 29 | run: crystal spec --order=random --error-on-warnings 30 | 31 | - name: Collect package information 32 | run: | 33 | echo "BINARY_NAME=bin/$(cat shard.yml |grep targets -A1|tail -n1 |sed 's#[ :]##g')" >> $GITHUB_ENV 34 | echo "PKG_ARCH=x86_64" >> $GITHUB_ENV 35 | echo "PLATFORM=unknown-linux-musl.tar.gz" >> $GITHUB_ENV 36 | echo "BUILD_ARGS=--static --link-flags=\"-s -Wl,-z,relro,-z,now\"" >> $GITHUB_ENV 37 | 38 | - name: Set asset name 39 | run: | 40 | echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $GITHUB_ENV 41 | 42 | - name: Build release binary 43 | id: release 44 | run: | 45 | echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $GITHUB_OUTPUT 46 | shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} 47 | tar zcvf ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}} LICENSE 48 | 49 | - name: Release 50 | uses: softprops/action-gh-release@v1 51 | if: startsWith(github.ref, 'refs/tags/') 52 | with: 53 | files: | 54 | ${{steps.release.outputs.ASSERT_NAME}} 55 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: 2 | workflow_call: 3 | 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Cache shards 9 | uses: actions/cache@v2 10 | with: 11 | path: ~/.cache/shards 12 | key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} 13 | restore-keys: ${{ runner.os }}-shards- 14 | 15 | - name: Download source 16 | uses: actions/checkout@v4 17 | 18 | - name: Install Crystal 19 | uses: crystal-lang/install-crystal@v1 20 | 21 | - name: Install shards 22 | run: shards check || shards install --without-development 23 | 24 | - name: Check formatting 25 | run: crystal tool format --check 26 | - name: Run Ameba 27 | run: bin/ameba 28 | 29 | - name: Run tests 30 | run: crystal spec --order=random --error-on-warnings 31 | 32 | - name: Build docs 33 | run: crystal docs 34 | 35 | - name: Deploy docs 36 | uses: peaceiris/actions-gh-pages@v3 37 | with: 38 | github_token: ${{ secrets.GITHUB_TOKEN }} 39 | publish_dir: ./docs 40 | -------------------------------------------------------------------------------- /.github/workflows/gnu_x86_64_release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*.*.*" 5 | branches: 6 | - master 7 | schedule: 8 | - cron: "15 3 * * 1" # Every monday at 3:15 AM 9 | 10 | jobs: 11 | call-ci: 12 | uses: ./.github/workflows/ci.yml 13 | secrets: inherit 14 | build: 15 | needs: call-ci 16 | runs-on: ubuntu-latest 17 | steps: 18 | - name: Cache shards 19 | uses: actions/cache@v4 20 | with: 21 | path: ~/.cache/shards 22 | key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} 23 | restore-keys: ${{ runner.os }}-shards- 24 | 25 | - name: Download source 26 | uses: actions/checkout@v4 27 | 28 | - name: Install Crystal 29 | uses: crystal-lang/install-crystal@v1 30 | 31 | - name: Install shards 32 | run: shards check || shards install --without-development 33 | - name: Collect package information 34 | run: | 35 | echo "BINARY_NAME=bin/$(cat shard.yml |grep targets -A1|tail -n1 |sed 's#[ :]##g')" >> $GITHUB_ENV 36 | echo "PKG_ARCH=x86_64" >> $GITHUB_ENV 37 | echo "PLATFORM=unknown-linux-gnu.tar.gz" >> $GITHUB_ENV 38 | echo "BUILD_ARGS=--link-flags=\"-s -Wl,-z,relro,-z,now\"" >> $GITHUB_ENV 39 | 40 | - name: Set asset name 41 | run: | 42 | echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $GITHUB_ENV 43 | 44 | - name: Build release binary 45 | id: release 46 | run: | 47 | echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $GITHUB_OUTPUT 48 | shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} 49 | tar zcvf ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}} LICENSE 50 | 51 | - name: Release 52 | uses: softprops/action-gh-release@v1 53 | if: startsWith(github.ref, 'refs/tags/') 54 | with: 55 | files: | 56 | ${{steps.release.outputs.ASSERT_NAME}} 57 | -------------------------------------------------------------------------------- /.github/workflows/macos_release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | tags: 4 | - "v*.*.*" 5 | 6 | jobs: 7 | build: 8 | runs-on: macos-latest 9 | steps: 10 | - name: Cache shards 11 | uses: actions/cache@v2 12 | with: 13 | path: ~/.cache/shards 14 | key: ${{ runner.os }}-shards-${{ hashFiles('shard.yml') }} 15 | restore-keys: ${{ runner.os }}-shards- 16 | 17 | - name: Download source 18 | uses: actions/checkout@v4 19 | 20 | - name: Install Crystal 21 | uses: crystal-lang/install-crystal@v1 22 | 23 | - name: Install shards 24 | run: shards check || shards install --without-development 25 | 26 | - name: Check formatting 27 | run: crystal tool format --check 28 | 29 | - name: Run tests 30 | run: crystal spec --order=random --error-on-warnings 31 | 32 | - name: Collect package information 33 | run: | 34 | echo "BINARY_NAME=bin/$(cat shard.yml |grep targets -A1|tail -n1 |sed 's#[ :]##g')" >> $GITHUB_ENV 35 | echo "PKG_ARCH=x86_64" >> $GITHUB_ENV 36 | echo "PLATFORM=apple-darwin.tar.gz" >> $GITHUB_ENV 37 | echo "BUILD_ARGS=" >> $GITHUB_ENV 38 | 39 | - name: Set asset name 40 | run: | 41 | echo "ASSERT_NAME=${{env.BINARY_NAME}}-${{github.ref_name}}-${{env.PKG_ARCH}}-${{env.PLATFORM}}" >> $GITHUB_ENV 42 | 43 | - name: Build release binary 44 | id: release 45 | run: | 46 | echo "ASSERT_NAME=${{env.ASSERT_NAME}}" >> $GITHUB_OUTPUT 47 | shards build --production --progress --no-debug -Dstrict_multi_assign -Dno_number_autocast ${{env.BUILD_ARGS}} 48 | tar zcvf ${{env.ASSERT_NAME}} ${{env.BINARY_NAME}} LICENSE 49 | 50 | - name: Release 51 | uses: softprops/action-gh-release@v1 52 | if: startsWith(github.ref, 'refs/tags/') 53 | with: 54 | files: | 55 | ${{steps.release.outputs.ASSERT_NAME}} 56 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | test-app/log 2 | test-app/pids 3 | test-app/procodile.log 4 | global.yml 5 | bin/* 6 | /procodile 7 | Procfile.* 8 | /lib 9 | /bin/ameba* 10 | /new_pids 11 | *.pid 12 | .tool-versions -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2016 Adam Cooke. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | -include Makefile.local # for optional local options 2 | 3 | NAME = procodile 4 | 5 | COMPILER ?= crystal 6 | SHARDS ?= shards 7 | 8 | SOURCES != find src -name '*.cr' 9 | LIB_SOURCES != find lib -name '*.cr' 2>/dev/null 10 | SPEC_SOURCES != find spec -name '*.cr' 2>/dev/null 11 | 12 | CRYSTAL_ENTRY_FILE != cat shard.yml |grep main: |cut -d: -f2|cut -d" " -f2 13 | OUTPUT_FILE != cat shard.yml |grep main: -B1 |head -n1 |awk '{print $$1}'|awk -F: '{print $$1}' 14 | CRYSTAL_ENTRY_PATH := $(shell pwd)/$(CRYSTAL_ENTRY_FILE) 15 | 16 | CACHE_DIR != $(COMPILER) env CRYSTAL_CACHE_DIR 17 | CACHE_DIR := $(CACHE_DIR)/$(subst /,-,${shell echo $(CRYSTAL_ENTRY_PATH) |cut -c2-}) 18 | 19 | FLAGS ?= --progress -Dstrict_multi_assign -Dno_number_autocast -Dpreview_overload_order 20 | RELEASE_FLAGS ?= --no-debug --link-flags=-s --release --progress -Dstrict_multi_assign -Dno_number_autocast -Dpreview_overload_order 21 | 22 | # INSTALL: 23 | DESTDIR ?= /usr/local 24 | BINDIR ?= $(DESTDIR)/bin 25 | INSTALL ?= /usr/bin/install 26 | 27 | O := bin/$(OUTPUT_FILE) 28 | 29 | .PHONY: all 30 | all: build ## build [default] 31 | 32 | .PHONY: build 33 | build: $(O) ## Build the application binary 34 | 35 | $(O): $(SOURCES) $(LIB_SOURCES) lib bin 36 | $(COMPILER) build $(FLAGS) $(CRYSTAL_ENTRY_FILE) -o $(O) 37 | 38 | # 注意, 这些不带 .PHONY 通常都是真实文件名或目录名 39 | lib: ## Run shards install to install dependencies 40 | $(SHARDS) install 41 | 42 | .PHONY: spec 43 | spec: $(SPEC_SOURCES) $(SOURCES) $(LIB_SOURCES) lib bin ## Run spec 44 | $(COMPILER) spec $(FLAGS) --order=random --error-on-warnings 45 | 46 | .PHONY: format 47 | format: ## Apply source code formatting 48 | $(COMPILER) tool format src spec 49 | 50 | .PHONY: install 51 | install: release ## Install the compiler at DESTDIR 52 | $(INSTALL) -d -m 0755 "$(BINDIR)/" 53 | $(INSTALL) -m 0755 "$(O)" "$(BINDIR)/$(NAME)" 54 | 55 | .PHONY: uninstall 56 | uninstall: ## Uninstall the compiler from DESTDIR 57 | rm -f "$(BINDIR)/$(NAME)" 58 | 59 | .PHONY: check 60 | check: ## Check dependencies, run shards install if necessary 61 | $(SHARDS) check || $(SHARDS) install 62 | 63 | .PHONY: clean 64 | clean: ## Delete built binary 65 | rm -f $(O) 66 | 67 | .PHONY: cleanall 68 | cleanall: clean # Delete built binary with cache 69 | rm -rf ${CACHE_DIR} 70 | 71 | .PHONY: test 72 | test: ## Run test script 73 | @scripts/test.sh 74 | 75 | .PHONY: release 76 | release: $(SOURCES) $(LIB_SOURCES) lib bin ## Build release binary 77 | $(COMPILER) build $(RELEASE_FLAGS) $(CRYSTAL_ENTRY_FILE) -o $(O) 78 | 79 | bin: 80 | @mkdir -p bin 81 | 82 | .PHONY: help 83 | help: ## Show this help 84 | @echo 85 | @printf '\033[34mtargets:\033[0m\n' 86 | @grep -hE '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) |\ 87 | sort |\ 88 | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' 89 | @echo 90 | @printf '\033[34moptional variables:\033[0m\n' 91 | @grep -hE '^[a-zA-Z_-]+ \?=.*?## .*$$' $(MAKEFILE_LIST) |\ 92 | sort |\ 93 | awk 'BEGIN {FS = " \\?=.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' 94 | @echo 95 | @printf '\033[34mrecipes:\033[0m\n' 96 | @grep -hE '^##.*$$' $(MAKEFILE_LIST) |\ 97 | awk 'BEGIN {FS = "## "}; /^## [a-zA-Z_-]/ {printf " \033[36m%s\033[0m\n", $$2}; /^## / {printf " %s\n", $$2}' 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Procodile 🐊 2 | 3 | Running & deploying Ruby apps to places like [Viaduct](https://viaduct.io) & Heroku is really easy but running processes on actual servers is less fun. Procodile aims to take some the stress out of running your Ruby/Rails apps and give you some of the useful process management features you get from the takes of the PaaS providers. 4 | 5 | Procodile is a bit like [Foreman](https://github.com/ddollar/foreman) but things are designed to run in the background (as well as the foreground if you prefer) and there's a supervisor which keeps an eye on your processes and will respawn them if they die. 6 | 7 | Procodile works out of the box with your existing `Procfile`. 8 | 9 | * [Watch a quick screencast](https://vimeo.com/188041935) 10 | * [Read documentation](https://github.com/adamcooke/procodile/wiki) 11 | * [View on RubyGems](https://rubygems.org/gems/procodile) 12 | * [Check the CHANGELOG](https://github.com/adamcooke/procodile/blob/master/CHANGELOG.md) 13 | 14 | ![Screenshot](https://share.adam.ac/16/cAZRKUM7.png) 15 | -------------------------------------------------------------------------------- /scripts/bar.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | while true; do 6 | echo "$(date) - Hello, World!" 1>&2 7 | echo $foo 8 | echo $bar 9 | echo $PORT 10 | sleep 1 11 | done 12 | -------------------------------------------------------------------------------- /scripts/foo.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | name=${1-foo} 6 | 7 | while true; do 8 | echo "$(date) - Hello, World! ${name}" 1>&2 9 | echo $foo 10 | echo $PORT 11 | sleep 1 12 | done 13 | -------------------------------------------------------------------------------- /scripts/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eu 4 | 5 | ROOT=${0%/*}/.. 6 | 7 | cd $ROOT 8 | 9 | if perl -v >/dev/null 2>/dev/null; then 10 | RESET=`perl -e 'print("\e[0m")'` 11 | BOLD=`perl -e 'print("\e[1m")'` 12 | YELLOW=`perl -e 'print("\e[33m")'` 13 | BLUE_BG=`perl -e 'print("\e[44m")'` 14 | elif python -V >/dev/null 2>/dev/null; then 15 | RESET=`echo 'import sys; sys.stdout.write("\033[0m")' | python` 16 | BOLD=`echo 'import sys; sys.stdout.write("\033[1m")' | python` 17 | YELLOW=`echo 'import sys; sys.stdout.write("\033[33m")' | python` 18 | BLUE_BG=`echo 'import sys; sys.stdout.write("\033[44m")' | python` 19 | else 20 | RESET= 21 | BOLD= 22 | YELLOW= 23 | BLUE_BG= 24 | fi 25 | 26 | function header() 27 | { 28 | local title="$1" 29 | echo "${BLUE_BG}${YELLOW}${BOLD}${title}${RESET}" 30 | echo "------------------------------------------" 31 | sleep 1 32 | } 33 | 34 | cat < Procfile 35 | app1: sh ${ROOT}/scripts/foo.sh 36 | app2: sh ${ROOT}/scripts/foo.sh 37 | app3: sh ${ROOT}/scripts/foo.sh 38 | HEREDOC 39 | 40 | cat <<'HEREDOC' > Procfile.local 41 | app_name: test 42 | pid_root: new_pids 43 | env: 44 | foo: foo 45 | 46 | processes: 47 | app1: 48 | allocate_port_from: 28128 49 | app2: 50 | allocate_port_from: 28320 51 | app3: 52 | allocate_port_from: 28502 53 | HEREDOC 54 | 55 | header 'Running spec' 56 | crystal spec 57 | header 'Building ...' 58 | header "Make sure print \`(15) Successful' to pass the test." 59 | which shards && [ -f shard.yml ] && shards build 60 | bin/procodile 61 | bin/procodile kill && sleep 3 # ensure kill before test. 62 | header '(1) Checking procodile start ...' 63 | bin/procodile start && sleep 3 64 | bin/procodile -r spec/apps/basic/ start && sleep 3 65 | header '(2) Checking procodile status --simple ...' 66 | bin/procodile status --simple |grep '^OK || app1\[1\], app2\[1\], app3\[1\]$' 67 | header '(2.1) Checking PORT envs' 68 | bin/procodile status |grep 'app1\.[0-9]*' |grep -o 'port:[0-9]*' |grep '28128' 69 | bin/procodile status |grep 'app2\.[0-9]*' |grep -o 'port:[0-9]*' |grep '28320' 70 | bin/procodile status |grep 'app3\.[0-9]*' |grep -o 'port:[0-9]*' |grep '28502' 71 | [ -s new_pids/procodile.pid ] 72 | header '(3) Checking procodile restart when started ...' 73 | bin/procodile restart && sleep 3 74 | bin/procodile status --simple |grep '^OK || app1\[1\], app2\[1\], app3\[1\]$' 75 | header '(4) Checking procodile stop -papp1,app2 ...' 76 | bin/procodile stop -papp1,app2 && sleep 3 77 | bin/procodile status --simple |grep '^Issues || app1 has 0 instances (should have 1), app2 has 0 instances (should have 1)$' 78 | header '(5) Checking procodile stop ...' 79 | bin/procodile stop && sleep 3 80 | bin/procodile status --simple |grep '^Issues || app1 has 0 instances (should have 1), app2 has 0 instances (should have 1), app3 has 0 instances (should have 1)$' 81 | header '(5.1) Checking procodile start when stopped ...' 82 | bin/procodile start && sleep 3 83 | bin/procodile status --simple |grep '^OK || app1\[1\], app2\[1\], app3\[1\]$' 84 | header '(5.2) Checking procodile stop when started ...' 85 | bin/procodile stop && sleep 3 86 | bin/procodile status --simple |grep '^Issues || app1 has 0 instances (should have 1), app2 has 0 instances (should have 1), app3 has 0 instances (should have 1)$' 87 | header '(6) Checking procodile restart when stopped ...' 88 | bin/procodile restart && sleep 3 89 | bin/procodile status --simple |grep '^OK || app1\[1\], app2\[1\], app3\[1\]$' 90 | header '(7) Checking procodile status ...' 91 | bin/procodile status 92 | 93 | header '(8) Change Procfile.local to set quantity of app1 from 1 to 2 ...' 94 | 95 | cat <<'HEREDOC' > Procfile.local 96 | app_name: test 97 | pid_root: new_pids 98 | env: 99 | foo: foo 100 | 101 | processes: 102 | app1: 103 | allocate_port_from: 28128 104 | quantity: 2 105 | app2: 106 | allocate_port_from: 28320 107 | app3: 108 | allocate_port_from: 28502 109 | HEREDOC 110 | 111 | header '(9) Checking procodile check_concurrency ...' 112 | bin/procodile check_concurrency 113 | bin/procodile status --simple |grep '^OK || app1\[2\], app2\[1\], app3\[1\]$' 114 | header '(9.1) Checking PORT envs for app1' 115 | bin/procodile status |grep 'app1\.[0-9]*' |grep -o 'port:[0-9]*' |grep '28128' 116 | bin/procodile status |grep 'app1\.[0-9]*' |grep -o 'port:[0-9]*' |grep '28129' 117 | header '(10) Checking procodile log ...' 118 | bin/procodile log 119 | 120 | header '(11) Change Procfile to set app3 lunch bar.sh instead of foo.sh' 121 | 122 | cat < Procfile 123 | app1: sh ${ROOT}/scripts/foo.sh 124 | app2: sh ${ROOT}/scripts/foo.sh 125 | app3: sh ${ROOT}/scripts/bar.sh 126 | HEREDOC 127 | 128 | header '(12) Checking procodile restart will failed when run app3.sh ...' 129 | bin/procodile restart && sleep 3 130 | header '(12.1) Checking procodile restart app3.sh Unknown status ...' 131 | bin/procodile status |grep -F 'app3.5' |grep -F 'Unknown' 132 | 133 | while ! bin/procodile status |grep -F 'app3.5' |grep -F 'Failed' |grep -F 'respawns:5'; do 134 | sleep 1 135 | echo 'Waiting respawns to become 5' 136 | done 137 | 138 | header '(12.1) Checking procodile restart app3.sh Failed status ...' 139 | bin/procodile status |grep -F 'app3.5' |grep -F 'Failed' 140 | 141 | header '(13) Change Procfile to set correct env for app3.sh' 142 | 143 | cat <<'HEREDOC' > Procfile.local 144 | app_name: test 145 | pid_root: new_pids 146 | env: 147 | foo: foo 148 | 149 | processes: 150 | app1: 151 | allocate_port_from: 28128 152 | quantity: 2 153 | app2: 154 | allocate_port_from: 28320 155 | quantity: 1 156 | app3: 157 | env: 158 | bar: bar 159 | HEREDOC 160 | 161 | header '(14) Checking procodile restart -papp3 ...' 162 | bin/procodile restart -papp3 && sleep 3 163 | bin/procodile status --simple |grep '^OK || app1\[2\], app2\[1\], app3\[1\]$' 164 | bin/procodile kill && sleep 3 165 | bin/procodile -r spec/apps/basic/ kill 166 | 167 | header '(15) Successful' 168 | -------------------------------------------------------------------------------- /shard.lock: -------------------------------------------------------------------------------- 1 | version: 2.0 2 | shards: 3 | ameba: 4 | git: https://github.com/crystal-ameba/ameba.git 5 | version: 1.6.4 6 | 7 | inotify: 8 | git: https://github.com/petoem/inotify.cr.git 9 | version: 1.0.3+git.commit.ddc795fd4d65633d4173b8103e6484c45399ebf4 10 | 11 | markd: 12 | git: https://github.com/icyleaf/markd.git 13 | version: 0.5.0 14 | 15 | reply: 16 | git: https://github.com/i3oris/reply.git 17 | version: 0.3.1+git.commit.db423dae3dd34c6ba5e36174653a0c109117a167 18 | 19 | source-typer: 20 | git: https://github.com/vici37/cr-source-typer.git 21 | version: 0.3.0+git.commit.b71b2b32cfae04bddcbd5d5dc22681acea51dbb9 22 | 23 | tail: 24 | git: https://github.com/j8r/tail.cr.git 25 | version: 0.3.1 26 | 27 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: procodile 2 | version: 1.1.0 3 | 4 | authors: 5 | - Adam Cooke 6 | - Billy.Zheng 7 | 8 | targets: 9 | procodile: 10 | main: src/procodile.cr 11 | 12 | dependencies: 13 | tail: 14 | github: j8r/tail.cr 15 | 16 | development_dependencies: 17 | ameba: 18 | github: crystal-ameba/ameba 19 | source-typer: 20 | github: Vici37/cr-source-typer 21 | 22 | crystal: 1.14.0 23 | 24 | license: MIT 25 | -------------------------------------------------------------------------------- /spec/apps/basic/Procfile: -------------------------------------------------------------------------------- 1 | web: ruby process.rb web 2 | -------------------------------------------------------------------------------- /spec/apps/basic/process.rb: -------------------------------------------------------------------------------- 1 | puts "This is #{ENV['PROC_NAME']}" 2 | trap("TERM") { Process.exit } 3 | loop do 4 | sleep 1 5 | end 6 | -------------------------------------------------------------------------------- /spec/apps/full/Procfile: -------------------------------------------------------------------------------- 1 | proc1: ruby process.rb 2 | proc2: ruby process.rb 3 | proc3: ruby process.rb 4 | proc4: ruby process.rb 5 | -------------------------------------------------------------------------------- /spec/apps/full/Procfile.options: -------------------------------------------------------------------------------- 1 | app_name: specapp 2 | pid_root: tmp/pids 3 | log_path: log/procodile.log 4 | console_command: irb -Ilib 5 | exec_prefix: bundle exec 6 | env: 7 | RAILS_ENV: production 8 | FRUIT: apple 9 | VEGETABLE: potato 10 | PORT: 3000 11 | processes: 12 | proc1: 13 | quantity: 2 14 | restart_mode: USR2 15 | term_signal: TERM 16 | allocate_port_from: 3005 17 | proxy_address: 127.0.0.1 18 | proxy_port: 2018 19 | network_protocol: tcp 20 | -------------------------------------------------------------------------------- /spec/apps/full/process.rb: -------------------------------------------------------------------------------- 1 | puts "This is #{ENV['PROC_NAME']}" 2 | trap("TERM") { Process.exit } 3 | loop do 4 | sleep 1 5 | end 6 | -------------------------------------------------------------------------------- /spec/apps/slow/Procfile: -------------------------------------------------------------------------------- 1 | slow-worker: ruby process.rb slow-worker 2 | -------------------------------------------------------------------------------- /spec/apps/slow/Procfile.options: -------------------------------------------------------------------------------- 1 | processes: 2 | slow-worker: 3 | quantity: 1 4 | restart_mode: start-term 5 | -------------------------------------------------------------------------------- /spec/apps/slow/process.rb: -------------------------------------------------------------------------------- 1 | puts "This is #{ENV['PROC_NAME']}. It stops slowly." 2 | trap("TERM") { sleep 10; Process.exit } 3 | 4 | loop do 5 | sleep 1 6 | end 7 | -------------------------------------------------------------------------------- /spec/spec_helper.cr: -------------------------------------------------------------------------------- 1 | File.touch("Procfile") 2 | APPS_ROOT = File.expand_path("apps", __DIR__) 3 | require "spec" 4 | require "yaml" 5 | require "../src/procodile" 6 | -------------------------------------------------------------------------------- /spec/specs/app_determination_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/procodile/app_determination" 3 | 4 | describe Procodile::AppDetermination do 5 | it "should allow root and procfile to be provided" do 6 | ap = Procodile::AppDetermination.new( 7 | pwd: "/", 8 | given_root: "/app", 9 | given_procfile: "Procfile", 10 | ) 11 | ap.root.should eq "/app" 12 | ap.procfile.should eq "/app/Procfile" 13 | end 14 | 15 | it "should normalize the trailing slashes" do 16 | ap = Procodile::AppDetermination.new( 17 | pwd: "/", 18 | given_root: "/app/", 19 | given_procfile: "Procfile", 20 | ) 21 | ap.root.should eq "/app" 22 | ap.procfile.should eq "/app/Procfile" 23 | end 24 | 25 | it "should allow only given_root provided" do 26 | ap = Procodile::AppDetermination.new( 27 | pwd: "/home", 28 | given_root: "/some/app", 29 | given_procfile: nil, 30 | ) 31 | ap.root.should eq "/some/app" 32 | ap.procfile.should be_nil 33 | end 34 | 35 | it "should allow only given_procfile provided" do 36 | ap = Procodile::AppDetermination.new( 37 | pwd: "/app", 38 | given_root: nil, 39 | given_procfile: "/myapps/Procfile", 40 | ) 41 | ap.root.should eq "/myapps" 42 | ap.procfile.should eq "/myapps/Procfile" 43 | end 44 | 45 | it "should use global_options" do 46 | yaml = <<-'HEREDOC' 47 | - 48 | name: Widgets App 49 | root: /path/to/widgets/app 50 | - 51 | name: Another App 52 | root: /path/to/another/app 53 | HEREDOC 54 | 55 | ap = Procodile::AppDetermination.new( 56 | pwd: "/myapps", 57 | given_root: nil, 58 | given_procfile: nil, 59 | global_options: Array(Procodile::Config::GlobalOption).from_yaml(yaml) 60 | ) 61 | ap.set_app_id_and_find_root_and_procfile(1) 62 | ap.root.should eq "/path/to/another/app" 63 | ap.procfile.should be_nil 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /spec/specs/cli_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/procodile/cli" 3 | 4 | describe Procodile::CLI do 5 | context "an application with a Procfile" do 6 | config = Procodile::Config.new(root: File.join(APPS_ROOT, "full")) 7 | cli = Procodile::CLI.new(config) 8 | cli.config = config 9 | 10 | it "should run help command" do 11 | command = cli.class.commands["help"] 12 | command.should be_a Procodile::CLI::Command 13 | command.name.should eq "help" 14 | command.description.should eq "Shows this help output" 15 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 16 | command.callable.should be_a Proc(Nil) 17 | command.callable.call 18 | end 19 | 20 | it "should run kill command" do 21 | command = cli.class.commands["kill"] 22 | command.should be_a Procodile::CLI::Command 23 | command.name.should eq "kill" 24 | command.description.should eq "Forcefully kill all known processes" 25 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 26 | command.callable.should be_a Proc(Nil) 27 | command.callable.call 28 | end 29 | 30 | it "should run start command" do 31 | command = cli.class.commands["start"] 32 | command.should be_a Procodile::CLI::Command 33 | command.name.should eq "start" 34 | command.description.should eq "Starts processes and/or the supervisor" 35 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 36 | command.callable.should be_a Proc(Nil) 37 | # command.callable.call 38 | end 39 | 40 | it "should run stop command" do 41 | command = cli.class.commands["stop"] 42 | command.should be_a Procodile::CLI::Command 43 | command.name.should eq "stop" 44 | command.description.should eq "Stops processes and/or the supervisor" 45 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 46 | command.callable.should be_a Proc(Nil) 47 | # command.callable.call 48 | end 49 | 50 | it "should run status command" do 51 | command = cli.class.commands["status"] 52 | command.should be_a Procodile::CLI::Command 53 | command.name.should eq "status" 54 | command.description.should eq "Show the current status of processes" 55 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 56 | command.callable.should be_a Proc(Nil) 57 | # command.callable.call 58 | end 59 | 60 | it "should run exec command" do 61 | command = cli.class.commands["exec"] 62 | command.should be_a Procodile::CLI::Command 63 | command.name.should eq "exec" 64 | command.description.should eq "Execute a command within the environment" 65 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 66 | command.callable.should be_a Proc(Nil) 67 | # command.callable.call 68 | end 69 | 70 | it "should run check_concurrency command" do 71 | command = cli.class.commands["check_concurrency"] 72 | command.should be_a Procodile::CLI::Command 73 | command.name.should eq "check_concurrency" 74 | command.description.should eq "Check process concurrency" 75 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 76 | command.callable.should be_a Proc(Nil) 77 | # command.callable.call 78 | end 79 | 80 | it "should run log command" do 81 | command = cli.class.commands["log"] 82 | command.should be_a Procodile::CLI::Command 83 | command.name.should eq "log" 84 | command.description.should eq "Open/stream a Procodile log file" 85 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 86 | command.callable.should be_a Proc(Nil) 87 | # command.callable.call 88 | end 89 | 90 | it "should run restart command" do 91 | command = cli.class.commands["restart"] 92 | command.should be_a Procodile::CLI::Command 93 | command.name.should eq "restart" 94 | command.description.should eq "Restart processes" 95 | command.options.should be_a Proc(OptionParser, Procodile::CLI, Nil) 96 | command.callable.should be_a Proc(Nil) 97 | # command.callable.call 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /spec/specs/config_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/procodile/config" 3 | 4 | describe Procodile::Config do 5 | context "an application with a Procfile" do 6 | config = Procodile::Config.new(root: File.join(APPS_ROOT, "basic")) 7 | 8 | it "should have a default procfile path and options_path" do 9 | config.procfile_path.should eq File.join(APPS_ROOT, "basic", "Procfile") 10 | config.options_path.should eq File.join(APPS_ROOT, "basic", "Procfile.options") 11 | config.local_options_path.should eq File.join(APPS_ROOT, "basic", "Procfile.local") 12 | end 13 | 14 | it "should not have any options" do 15 | config.options.should be_a Procodile::Config::Option 16 | config.options.should eq Procodile::Config::Option.new 17 | end 18 | 19 | it "should not have any local options" do 20 | config.local_options.should be_a Procodile::Config::Option 21 | config.local_options.should eq Procodile::Config::Option.new 22 | end 23 | 24 | it "should have a determined pid root and socket path" do 25 | config.pid_root.should eq File.join(APPS_ROOT, "basic", "pids") 26 | config.sock_path.should eq File.join(APPS_ROOT, "basic", "pids", "procodile.sock") 27 | end 28 | 29 | it "should have a supervisor pid path" do 30 | config.supervisor_pid_path.should eq File.join(APPS_ROOT, "basic", "pids", "procodile.pid") 31 | end 32 | 33 | it "should have a determined log file" do 34 | config.log_path.should eq File.join(APPS_ROOT, "basic", "procodile.log") 35 | end 36 | 37 | it "should not have a log root" do 38 | config.log_root.should be_nil 39 | end 40 | 41 | context "the process list" do 42 | process_list = config.processes 43 | 44 | it "should be a hash" do 45 | process_list.should be_a Hash(String, Procodile::Process) 46 | end 47 | 48 | context "a created process" do 49 | process = config.processes["web"] 50 | 51 | it "should be a process object" do 52 | process.should be_a Procodile::Process 53 | end 54 | 55 | it "should have a suitable command" do 56 | process.command.should eq "ruby process.rb web" 57 | end 58 | 59 | it "should have a log color" do 60 | process.log_color.should eq Colorize::ColorANSI::Magenta 61 | end 62 | end 63 | end 64 | end 65 | 66 | context "an application without a Procfile" do 67 | it "should raise an error" do 68 | expect_raises(Procodile::Error, /Procfile not found at/) do 69 | Procodile::Config.new(File.join(APPS_ROOT, "empty")) 70 | end 71 | end 72 | end 73 | 74 | context "an application with options" do 75 | config = Procodile::Config.new(root: File.join(APPS_ROOT, "full")) 76 | 77 | # it "should have options", focus: true do 78 | # # config.options.size.should_not eq 0 79 | # end 80 | 81 | it "should return the app name" do 82 | config.app_name.should eq "specapp" 83 | end 84 | 85 | it "should return a custom pid root" do 86 | config.pid_root.should eq File.join(APPS_ROOT, "full", "tmp/pids") 87 | end 88 | 89 | it "should have the socket in the custom pid root" do 90 | config.sock_path.should eq File.join(APPS_ROOT, "full", "tmp/pids/procodile.sock") 91 | end 92 | 93 | it "should have the supervisor pid in the custom pid root" do 94 | config.supervisor_pid_path.should eq File.join(APPS_ROOT, "full", "tmp/pids/procodile.pid") 95 | end 96 | 97 | it "should have environment variables" do 98 | config.environment_variables.should be_a Hash(String, String) 99 | config.environment_variables["FRUIT"].should eq "apple" 100 | end 101 | 102 | it "should stringify values on environment variables" do 103 | config.environment_variables["PORT"].should eq "3000" 104 | end 105 | 106 | it "should flatten environment variables that have environment variants" do 107 | config.environment_variables["VEGETABLE"].should eq "potato" 108 | end 109 | 110 | it "should a custom log path" do 111 | config.log_path.should eq File.join(APPS_ROOT, "full", "log/procodile.log") 112 | end 113 | 114 | it "should return a console command" do 115 | config.console_command.should eq "irb -Ilib" 116 | end 117 | 118 | it "should return an exec prefix" do 119 | config.exec_prefix.should eq "bundle exec" 120 | end 121 | 122 | it "should be able to return options for a process" do 123 | config.options_for_process("proc1").should be_a Procodile::Process::Option 124 | config.options_for_process("proc1").quantity.should eq 2 125 | config.options_for_process("proc1").restart_mode.should eq Signal::USR2 126 | config.options_for_process("proc2").should be_a Procodile::Process::Option 127 | config.options_for_process("proc2").should eq Procodile::Process::Option.new 128 | end 129 | end 130 | 131 | context "reloading configuration" do 132 | saved_procfile_content = "proc1: ruby process.rb 133 | proc2: ruby process.rb 134 | proc3: ruby process.rb 135 | proc4: ruby process.rb 136 | " 137 | 138 | saved_options_content = "app_name: specapp 139 | pid_root: tmp/pids 140 | log_path: log/procodile.log 141 | console_command: irb -Ilib 142 | exec_prefix: bundle exec 143 | env: 144 | RAILS_ENV: production 145 | FRUIT: apple 146 | VEGETABLE: potato 147 | PORT: 3000 148 | processes: 149 | proc1: 150 | quantity: 2 151 | restart_mode: USR2 152 | term_signal: TERM 153 | allocate_port_from: 3005 154 | proxy_address: 127.0.0.1 155 | proxy_port: 2018 156 | network_protocol: tcp 157 | " 158 | 159 | it "should add missing processes" do 160 | config = Procodile::Config.new(File.join(APPS_ROOT, "full")) 161 | 162 | config.process_list.size.should eq 4 163 | 164 | new_procfile_hash = Hash(String, String).from_yaml(saved_procfile_content) 165 | new_procfile_hash["proc5"] = "ruby process.rb" 166 | File.write(config.procfile_path, new_procfile_hash.to_yaml) 167 | 168 | config.reload 169 | 170 | config.process_list.size.should eq 5 171 | config.process_list["proc5"].should eq "ruby process.rb" 172 | 173 | File.write(config.procfile_path, saved_procfile_content) 174 | end 175 | 176 | it "should remove removed processes" do 177 | config = Procodile::Config.new(File.join(APPS_ROOT, "full")) 178 | 179 | config.process_list.size.should eq 4 180 | 181 | new_procfile_hash = Hash(String, String).from_yaml(saved_procfile_content) 182 | new_procfile_hash.delete("proc4") 183 | File.write(config.procfile_path, new_procfile_hash.to_yaml) 184 | 185 | config.reload 186 | 187 | config.process_list.size.should eq 3 188 | config.process_list["proc4"]?.should be_nil 189 | 190 | File.write(config.procfile_path, saved_procfile_content) 191 | end 192 | 193 | it "should update existing processes" do 194 | config = Procodile::Config.new(File.join(APPS_ROOT, "full")) 195 | 196 | config.process_list["proc4"].should eq "ruby process.rb" 197 | 198 | new_procfile_hash = Hash(String, String).from_yaml(saved_procfile_content) 199 | new_procfile_hash["proc4"] = "ruby process2.rb" 200 | File.write(config.procfile_path, new_procfile_hash.to_yaml) 201 | 202 | config.reload 203 | 204 | config.process_list["proc4"].should eq "ruby process2.rb" 205 | 206 | File.write(config.procfile_path, saved_procfile_content) 207 | end 208 | 209 | it "should update processes when options change" do 210 | config = Procodile::Config.new(File.join(APPS_ROOT, "full")) 211 | 212 | config.options_for_process("proc1").restart_mode.should eq Signal::USR2 213 | 214 | File.write(config.options_path, saved_options_content.sub("term-start", "usr2")) 215 | 216 | config.reload 217 | 218 | config.options_for_process("proc1").restart_mode.should eq Signal::USR2 219 | 220 | File.write(config.options_path, saved_options_content) 221 | end 222 | end 223 | end 224 | -------------------------------------------------------------------------------- /spec/specs/process_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | require "../../src/procodile/config" 3 | require "../../src/procodile/process" 4 | require "../../src/procodile/supervisor" 5 | 6 | describe Procodile::Process do 7 | config = Procodile::Config.new(root: File.join(APPS_ROOT, "full")) 8 | process = Procodile::Process.new(config, "proc1", "ruby process.rb", config.options_for_process("proc1")) 9 | 10 | it "should return correct attributes" do 11 | process.quantity.should be_a Int32 12 | process.quantity.should eq 2 13 | 14 | process.max_respawns.should be_a Int32 15 | process.max_respawns.should eq 5 16 | 17 | process.respawn_window.should be_a Int32 18 | process.respawn_window.should eq 3600 19 | 20 | process.log_path.should be_a String 21 | process.log_path.should end_with "apps/full/proc1.log" 22 | 23 | process.term_signal.should be_a Signal 24 | process.term_signal.should eq Signal::TERM 25 | 26 | process.restart_mode.should be_a Signal 27 | process.restart_mode.should eq Signal::USR2 28 | 29 | process.allocate_port_from.should be_a Int32 30 | process.allocate_port_from.should eq 3005 31 | 32 | process.proxy?.should be_a Bool 33 | process.proxy?.should be_true 34 | 35 | process.proxy_port.should be_a Int32 36 | process.proxy_port.should eq 2018 37 | 38 | process.proxy_address.should be_a String 39 | process.proxy_address.should eq "127.0.0.1" 40 | 41 | process.network_protocol.should be_a String 42 | process.network_protocol.should eq "tcp" 43 | end 44 | 45 | # it "should create a new instance" do 46 | # supervisor = Procodile::Supervisor.new(config) 47 | # process.create_instance 48 | # end 49 | end 50 | -------------------------------------------------------------------------------- /spec/specs/procfile_option_spec.cr: -------------------------------------------------------------------------------- 1 | require "../spec_helper" 2 | 3 | describe Procodile::Config::Option do 4 | it "should allow root and procfile to be provided" do 5 | procfile_option_file = File.join(APPS_ROOT, "full", "Procfile.options") 6 | procfile_option = Procodile::Config::Option.from_yaml(File.read(procfile_option_file)) 7 | procfile_option.app_name.should eq "specapp" 8 | procfile_option.pid_root.should eq "tmp/pids" 9 | procfile_option.log_path.should eq "log/procodile.log" 10 | procfile_option.exec_prefix.should eq "bundle exec" 11 | procfile_option.env.should eq({"RAILS_ENV" => "production", "FRUIT" => "apple", "VEGETABLE" => "potato", "PORT" => "3000"}) 12 | 13 | procfile_option.processes.should be_a Hash(String, Procodile::Process::Option) 14 | 15 | process_option = Procodile::Process::Option.new 16 | process_option.quantity = 2 17 | process_option.restart_mode = Signal::USR2 18 | process_option.term_signal = Signal::TERM 19 | process_option.allocate_port_from = 3005 20 | process_option.proxy_address = "127.0.0.1" 21 | process_option.proxy_port = 2018 22 | process_option.network_protocol = "tcp" 23 | 24 | procfile_option.processes.should eq({"proc1" => process_option}) 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /src/procodile.cr: -------------------------------------------------------------------------------- 1 | require "./requires" 2 | require "./procodile/cli" 3 | 4 | module Procodile 5 | VERSION = {{ 6 | `shards version "#{__DIR__}"`.chomp.stringify + 7 | " (rev " + 8 | `git rev-parse --short HEAD`.chomp.stringify + 9 | ")" 10 | }} 11 | 12 | class Error < Exception 13 | end 14 | 15 | private def self.root : String 16 | File.expand_path("..", __DIR__) 17 | end 18 | 19 | private def self.bin_path : String 20 | File.join(root, "bin", "procodile") 21 | end 22 | 23 | ORIGINAL_ARGV = ARGV.join(" ") 24 | command = ARGV[0]? || "help" 25 | options = {} of Symbol => String 26 | 27 | opt = OptionParser.new do |parser| 28 | parser.banner = "Usage: procodile #{command} [options]" 29 | 30 | parser.on("-r", "--root PATH", "The path to the root of your application") do |root| 31 | options[:root] = root 32 | end 33 | 34 | parser.on("--procfile PATH", "The path to the Procfile (defaults to: Procfile)") do |path| 35 | options[:procfile] = path 36 | end 37 | 38 | parser.on("-h", "--help", "Show this help message and exit") do 39 | abort parser, status: 0 40 | end 41 | 42 | parser.on("-v", "--version", "Show version") do 43 | abort VERSION, status: 0 44 | end 45 | 46 | parser.invalid_option do |flag| 47 | abort "Invalid option: #{flag}.\n\n#{parser}" 48 | end 49 | 50 | parser.missing_option do |flag| 51 | abort "Missing option for #{flag}\n\n#{parser}" 52 | end 53 | end 54 | 55 | # Get the global configuration file data 56 | global_config_path = ENV["PROCODILE_CONFIG"]? || "/etc/procodile" 57 | 58 | global_config = if File.file?(global_config_path) 59 | Array(Config::GlobalOption).from_yaml(File.read(global_config_path)) 60 | else 61 | [] of Config::GlobalOption 62 | end 63 | 64 | # Create a determination to work out where we want to load our app from 65 | ap = AppDetermination.new( 66 | FileUtils.pwd, 67 | options[:root]?, 68 | options[:procfile]?, 69 | global_config 70 | ) 71 | 72 | if ap.ambiguous? 73 | if (app_id = ENV["PROCODILE_APP_ID"]?) 74 | ap.set_app_id_and_find_root_and_procfile(app_id.to_i) 75 | elsif ap.app_options.empty? 76 | abort "Error: Could not find Procfile in #{FileUtils.pwd}/Procfile".colorize.red 77 | else 78 | puts "There are multiple applications configured in #{global_config_path}" 79 | puts "Choose an application:".colorize.light_gray.on_magenta 80 | 81 | ap.app_options.each do |i, app| 82 | col = i % 3 83 | print "#{(i + 1)}) #{app}"[0, 28].ljust(col != 2 ? 30 : 0, ' ') 84 | if col == 2 || i == ap.app_options.size - 1 85 | puts 86 | end 87 | end 88 | 89 | input = STDIN.gets 90 | if !input.nil? 91 | app_id = input.strip.to_i - 1 92 | 93 | if ap.app_options[app_id]? 94 | ap.set_app_id_and_find_root_and_procfile(app_id) 95 | else 96 | abort "Invalid app number: #{app_id + 1}" 97 | end 98 | end 99 | end 100 | end 101 | 102 | begin 103 | cli = CLI.new(config: Config.new(ap.root || "", ap.procfile)) 104 | 105 | if cli.class.commands[command]? && (option_proc = cli.class.commands[command].options) 106 | option_proc.call(opt, cli) 107 | end 108 | 109 | opt.parse 110 | 111 | # 112 | # For fix https://github.com/adamcooke/procodile/issues/30 113 | # Duplicate on this line is necessory for get new parsed ARGV. 114 | command = ARGV[0]? || "help" 115 | 116 | if command != "help" 117 | if cli.config.user && ENV["USER"] != cli.config.user 118 | STDERR.puts "Procodile must be run as #{cli.config.user}. Re-executing as #{cli.config.user}...".colorize.red 119 | 120 | ::Process.exec( 121 | command: "sudo -H -u #{cli.config.user} -- #{$0} #{ORIGINAL_ARGV}", 122 | shell: true 123 | ) 124 | end 125 | end 126 | 127 | cli.dispatch(command) 128 | rescue ex : Error 129 | abort "Error: #{ex.message}".colorize.red 130 | end 131 | end 132 | -------------------------------------------------------------------------------- /src/procodile/app_determination.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | # 3 | # This class is responsible for determining which application should be used 4 | # 5 | class AppDetermination 6 | @app_id : Int32? 7 | @given_root : String? 8 | 9 | getter root : String? 10 | getter procfile : String? 11 | 12 | # Start by creating an determination ased on the root and procfile that has been provided 13 | # to us by the user (from --root and/or --procfile) 14 | def initialize( 15 | @pwd : String, 16 | given_root : String?, 17 | @given_procfile : String?, 18 | @global_options : Array(Config::GlobalOption) = [] of Config::GlobalOption, 19 | ) 20 | @given_root = given_root ? expand_path(given_root, pwd) : nil 21 | 22 | calculate 23 | end 24 | 25 | # No root 26 | def ambiguous? : Bool 27 | !@root 28 | end 29 | 30 | # Choose which of the ambiguous options we want to choose 31 | def set_app_id_and_find_root_and_procfile(id : Int32) : Nil 32 | @app_id = id 33 | 34 | find_root_and_procfile_from_options(@global_options) 35 | end 36 | 37 | # Return an hash of possible options to settle the ambiguity 38 | def app_options : Hash(Int32, String) 39 | if ambiguous? 40 | hash = {} of Int32 => String 41 | 42 | @global_options.each_with_index do |option, i| 43 | hash[i] = option.name || option.root 44 | end 45 | 46 | hash 47 | else 48 | {} of Int32 => String 49 | end 50 | end 51 | 52 | private def calculate : Nil 53 | # Try and find something using the information that has been given to us by the user 54 | find_root_and_procfile(@pwd, @given_root, @given_procfile) 55 | 56 | # Otherwise, try and use the global config we have been given 57 | find_root_and_procfile_from_options(@global_options) if ambiguous? 58 | end 59 | 60 | private def find_root_and_procfile( 61 | pwd : String, 62 | given_root : String?, 63 | given_procfile : String?, 64 | ) : Nil 65 | case 66 | when given_root && given_procfile 67 | # The user has provided both the root and procfile, we can use these 68 | @root = expand_path(given_root) 69 | @procfile = expand_path(given_procfile, @root) 70 | when given_root && given_procfile.nil? 71 | # The user has given us a root, we'll use that as the root 72 | @root = expand_path(given_root) 73 | when given_root.nil? && given_procfile 74 | # The user has given us a procfile but no root. We will assume the procfile 75 | # is in the root of the directory 76 | @procfile = expand_path(given_procfile) 77 | @root = File.dirname(@procfile.not_nil!) 78 | else 79 | # The user has given us nothing. We will check to see if there's a Procfile 80 | # in the root of our current pwd 81 | if File.file?(File.join(pwd, "Procfile")) 82 | # If there's a procfile in our current pwd, we'll use our current 83 | # directory as the root. 84 | @root = pwd 85 | @procfile = "Procfile" 86 | @in_app_directory = true 87 | end 88 | end 89 | end 90 | 91 | private def expand_path(path : String, root : String? = nil) : String 92 | # Remove trailing slashes for normalization 93 | path = path.rstrip('/') 94 | 95 | if path.starts_with?('/') 96 | # If the path starts with a /, it's absolute. Do nothing. 97 | path 98 | else 99 | # Otherwise, if there's a root provided, it should be from the root 100 | # of that otherwise from the root of the current directory. 101 | root ? File.join(root, path) : File.join(@pwd, path) 102 | end 103 | end 104 | 105 | private def find_root_and_procfile_from_options( 106 | options : Config::GlobalOption | Array(Config::GlobalOption), 107 | ) : Nil 108 | case options 109 | when Config::GlobalOption 110 | # Use the current hash 111 | find_root_and_procfile(@pwd, options.root, options.procfile) 112 | when Array(Config::GlobalOption) 113 | # Global options is provides a list of apps. We need to know which one of 114 | # these we should be looking at. 115 | if (app_id = @app_id) 116 | find_root_and_procfile_from_options(options[app_id]) 117 | end 118 | end 119 | end 120 | end 121 | end 122 | -------------------------------------------------------------------------------- /src/procodile/cli.cr: -------------------------------------------------------------------------------- 1 | require "./core_ext/process" 2 | require "./app_determination" 3 | require "./config" 4 | require "./commands/*" 5 | 6 | module Procodile 7 | class CLI 8 | COMMANDS = [ 9 | {:help, "Shows this help output"}, 10 | {:kill, "Forcefully kill all known processes"}, 11 | {:start, "Starts processes and/or the supervisor"}, 12 | {:stop, "Stops processes and/or the supervisor"}, 13 | {:exec, "Execute a command within the environment"}, 14 | {:run, "Execute a command within the environment"}, 15 | {:check_concurrency, "Check process concurrency"}, 16 | {:log, "Open/stream a Procodile log file"}, 17 | {:restart, "Restart processes"}, 18 | {:status, "Show the current status of processes"}, 19 | {:console, "Open a console within the environment"}, 20 | ] 21 | property config : Config 22 | property options : Options = Options.new 23 | 24 | class_getter commands : Hash(String, Command) { {} of String => Command } 25 | 26 | @@options = {} of Symbol => Proc(OptionParser, CLI, Nil) 27 | 28 | {% begin %} 29 | {% for e in COMMANDS %} 30 | {% name = e[0] %} 31 | include {{ (name.camelcase + "Command").id }} 32 | {% end %} 33 | 34 | def initialize(@config : Config) 35 | {% for e in COMMANDS %} 36 | {% name = e[0] %} 37 | {% description = e[1] %} 38 | 39 | self.class.commands[{{ name.id.stringify }}] = Command.new( 40 | name: {{ name.id.stringify }}, 41 | description: {{ description.id.stringify }}, 42 | options: @@options[{{ name }}], 43 | callable: ->{{ name.id }} 44 | ) 45 | {% end %} 46 | end 47 | {% end %} 48 | 49 | def dispatch(command : String) : Nil 50 | if self.class.commands.has_key?(command) 51 | self.class.commands[command].callable.call 52 | else 53 | raise Error.new("Invalid command '#{command}'") 54 | end 55 | end 56 | 57 | private def supervisor_running? : Bool 58 | if File.exists?(@config.supervisor_pid_path) 59 | file_pid = File.read(@config.supervisor_pid_path).strip 60 | file_pid.empty? ? false : ::Process.exists?(file_pid.to_i64) 61 | else 62 | false 63 | end 64 | end 65 | 66 | private def process_names_from_cli_option : Array(String)? 67 | _processes = @options.processes 68 | 69 | if _processes 70 | processes = _processes.split(",") 71 | 72 | raise Error.new "No process names provided" if processes.empty? 73 | 74 | # processes.each do |process| 75 | # process_name, _ = process.split('.', 2) 76 | # unless @config.process_list.keys.includes?(process_name.to_s) 77 | # raise Error.new "Process '#{process_name}' is not configured. You may need to reload your config." 78 | # end 79 | # end 80 | 81 | processes 82 | end 83 | end 84 | 85 | private def self.options(name : Symbol, &block : Proc(OptionParser, CLI, Nil)) : Nil 86 | @@options[name] = block 87 | end 88 | 89 | struct Command 90 | getter name : String 91 | getter description : String 92 | getter options : Proc(OptionParser, CLI, Nil) 93 | getter callable : Proc(Nil) 94 | 95 | def initialize( 96 | @name : String, 97 | @description : String, 98 | @options : Proc(OptionParser, CLI, Nil), 99 | @callable : Proc(Nil), 100 | ) 101 | end 102 | end 103 | 104 | struct Options 105 | property? foreground : Bool? 106 | property? respawn : Bool? 107 | property? stop_when_none : Bool? 108 | property? proxy : Bool? 109 | property? json : Bool? 110 | property? json_pretty : Bool? 111 | property? simple : Bool? 112 | property? clean : Bool? 113 | property? follow : Bool? 114 | property? start_supervisor : Bool? 115 | property? start_processes : Bool? 116 | property? stop_supervisor : Bool? 117 | property? wait_until_supervisor_stopped : Bool? 118 | property? reload : Bool? 119 | property tag : String? 120 | property port_allocations : Hash(String, Int32)? 121 | property processes : String? # A String split by comma. 122 | property lines : Int32? 123 | property process : String? 124 | 125 | def initialize 126 | end 127 | end 128 | end 129 | end 130 | -------------------------------------------------------------------------------- /src/procodile/commands/check_concurrency_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module CheckConcurrencyCommand 4 | macro included 5 | options :check_concurrency do |opts, cli| 6 | opts.on( 7 | "--no-reload", 8 | "Do not reload the configuration before checking" 9 | ) do |processes| 10 | cli.options.reload = false 11 | end 12 | end 13 | end 14 | 15 | private def check_concurrency : Nil 16 | if supervisor_running? 17 | reply = ControlClient.run( 18 | @config.sock_path, 19 | "check_concurrency", 20 | reload: @options.reload? 21 | ).as NamedTuple(started: Array(Instance::Config), stopped: Array(Instance::Config)) 22 | 23 | if reply["started"].empty? && reply["stopped"].empty? 24 | puts "Processes are running as configured" 25 | else 26 | reply["started"].each do |instance| 27 | puts "#{"Started".colorize.green} #{instance.description} (PID: #{instance.pid})" 28 | end 29 | 30 | reply["stopped"].each do |instance| 31 | puts "#{"Stopped".colorize.red} #{instance.description} (PID: #{instance.pid})" 32 | end 33 | end 34 | else 35 | raise Error.new "Procodile supervisor isn't running" 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/procodile/commands/console_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module ConsoleCommand 4 | macro included 5 | options :console do |opts, cli| 6 | end 7 | end 8 | 9 | private def console : Nil 10 | if (cmd = @config.console_command) 11 | exec(cmd) 12 | else 13 | raise Error.new "No console command has been configured in the Procfile" 14 | end 15 | end 16 | end 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /src/procodile/commands/exec_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module ExecCommand 4 | macro included 5 | options :exec do |opts, cli| 6 | end 7 | end 8 | 9 | private def exec(command : String? = nil) : Nil 10 | desired_command = command || ARGV[1..].join(" ") 11 | 12 | if (prefix = @config.exec_prefix) 13 | desired_command = "#{prefix} #{desired_command}" 14 | end 15 | 16 | if desired_command.empty? 17 | raise Error.new "You need to specify a command to run \ 18 | (e.g. procodile run -- rake db:migrate)" 19 | else 20 | environment = @config.environment_variables 21 | 22 | unless ENV["PROCODILE_EXEC_QUIET"]?.try(&.to_i) == 1 23 | puts "Running with #{desired_command.colorize.yellow}" 24 | environment.each do |key, value| 25 | puts " #{key.colorize.blue} #{value}" 26 | end 27 | end 28 | 29 | begin 30 | Dir.cd(@config.root) 31 | 32 | ::Process.exec(desired_command, env: environment, shell: true) 33 | rescue e : RuntimeError 34 | raise Error.new e.message 35 | end 36 | end 37 | end 38 | end 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /src/procodile/commands/help_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module HelpCommand 4 | macro included 5 | options :help do |opts, cli| 6 | end 7 | end 8 | 9 | private def help : Nil 10 | puts "Welcome to Procodile v#{VERSION}".colorize.light_gray.on_magenta 11 | puts "For documentation see https://adam.ac/procodile." 12 | puts 13 | 14 | puts "The following commands are supported:" 15 | puts 16 | 17 | self.class.commands.to_a.sort_by { |x| x[0] }.to_h.each do |method, options| 18 | if options.description 19 | puts " #{method.to_s.ljust(18, ' ').colorize.blue} #{options.description}" 20 | end 21 | end 22 | 23 | puts 24 | puts "For details for the options available for each command, use the --help option." 25 | puts "For example 'procodile start --help'." 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/procodile/commands/kill_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module KillCommand 4 | macro included 5 | options :kill do |opts, cli| 6 | end 7 | end 8 | 9 | private def kill : Nil 10 | Dir[File.join(@config.pid_root, "*.pid")].each do |pid_path| 11 | name = pid_path.split('/').last.rstrip(".pid") 12 | pid = File.read(pid_path).to_i 13 | 14 | begin 15 | ::Process.signal(Signal::KILL, pid) 16 | puts "Sent KILL -9 to #{pid} (#{name})" 17 | rescue RuntimeError 18 | end 19 | 20 | FileUtils.rm(pid_path) 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /src/procodile/commands/log_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module LogCommand 4 | macro included 5 | options :log do |opts, cli| 6 | opts.on( 7 | "-f", 8 | "Wait for additional data and display it straight away" 9 | ) do 10 | cli.options.follow = true 11 | end 12 | 13 | opts.on( 14 | "-n LINES", 15 | "The number of previous lines to return" 16 | ) do |lines| 17 | cli.options.lines = lines.to_i 18 | end 19 | 20 | opts.on( 21 | "-p PROCESS", 22 | "--process PROCESS", 23 | "Show the log for a given process (rather than procodile)" 24 | ) do |process| 25 | cli.options.process = process 26 | end 27 | end 28 | end 29 | 30 | private def log : Nil 31 | if (process_opts = options.process) 32 | if (process = @config.processes[process_opts]) 33 | log_path = process.log_path 34 | else 35 | raise Error.new "Invalid process name '#{process_opts}'" 36 | end 37 | else 38 | log_path = @config.log_path 39 | end 40 | 41 | if File.exists?(log_path) 42 | Tail::File.open(log_path) do |tail_file| 43 | if options.follow? 44 | tail_file.follow { |str| puts str } 45 | elsif (line_count = options.lines) 46 | tail_file.last_lines(line_count) 47 | else 48 | tail_file.last_lines 49 | end 50 | end 51 | else 52 | raise Error.new "No file found at #{log_path}" 53 | end 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /src/procodile/commands/restart_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module RestartCommand 4 | macro included 5 | options :restart do |opts, cli| 6 | opts.on( 7 | "-p", 8 | "--processes a,b,c", 9 | "Only restart the listed processes or process types" 10 | ) do |processes| 11 | cli.options.processes = processes 12 | end 13 | 14 | opts.on( 15 | "-t", 16 | "--tag TAGNAME", 17 | "Tag all started processes with the given tag" 18 | ) do |tag| 19 | cli.options.tag = tag 20 | end 21 | end 22 | end 23 | 24 | private def restart : Nil 25 | raise Error.new "Procodile supervisor isn't running" unless supervisor_running? 26 | 27 | instance_configs = ControlClient.run( 28 | @config.sock_path, 29 | "restart", 30 | processes: process_names_from_cli_option, 31 | tag: @options.tag, 32 | ).as Array(Tuple(Instance::Config?, Instance::Config?)) 33 | 34 | if instance_configs.empty? 35 | puts "There are no processes to restart." 36 | else 37 | if instance_configs.first.to_a.compact[0].foreground? 38 | puts "Caution: When using the restart command in foreground mode, \ 39 | tends to be prone to failure, use it with caution." 40 | end 41 | 42 | instance_configs.each do |old_instance, new_instance| 43 | if old_instance && new_instance 44 | if old_instance.description == new_instance.description 45 | puts "#{"Restarted".colorize.magenta} #{old_instance.description}" 46 | else 47 | puts "#{"Restarted".colorize.magenta} #{old_instance.description} \ 48 | -> #{new_instance.description}" 49 | end 50 | elsif old_instance 51 | puts "#{"Stopped".colorize.red} #{old_instance.description}" 52 | elsif new_instance 53 | puts "#{"Started".colorize.green} #{new_instance.description}" 54 | end 55 | 56 | STDOUT.flush 57 | end 58 | end 59 | end 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /src/procodile/commands/run_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module RunCommand 4 | macro included 5 | options :run do |opts, cli| 6 | end 7 | end 8 | 9 | private def run(command : String? = nil) : Nil 10 | exec(command) 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /src/procodile/commands/start_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module StartCommand 4 | macro included 5 | options :start do |opts, cli| 6 | opts.on( 7 | "-p", 8 | "--processes a,b,c", 9 | "Only start the listed processes or process types" 10 | ) do |processes| 11 | cli.options.processes = processes 12 | end 13 | 14 | opts.on( 15 | "-t", 16 | "--tag TAGNAME", 17 | "Tag all started processes with the given tag" 18 | ) do |tag| 19 | cli.options.tag = tag 20 | end 21 | 22 | opts.on( 23 | "--no-supervisor", 24 | "Do not start a supervisor if its not running" 25 | ) do 26 | cli.options.start_supervisor = false 27 | end 28 | 29 | opts.on( 30 | "--no-processes", 31 | "Do not start any processes (only applicable when supervisor is stopped)" 32 | ) do 33 | cli.options.start_processes = false 34 | end 35 | 36 | opts.on( 37 | "-f", 38 | "--foreground", 39 | "Run the supervisor in the foreground" 40 | ) do 41 | cli.options.foreground = true 42 | end 43 | 44 | opts.on( 45 | "--clean", 46 | "Remove all previous pid and sock files before starting" 47 | ) do 48 | cli.options.clean = true 49 | end 50 | 51 | opts.on( 52 | "--no-respawn", 53 | "Disable respawning for all processes" 54 | ) do 55 | cli.options.respawn = false 56 | end 57 | 58 | opts.on( 59 | "--stop-when-none", 60 | "Stop the supervisor when all processes are stopped" 61 | ) do 62 | cli.options.stop_when_none = true 63 | end 64 | 65 | # opts.on("-x", "--proxy", "Enables the Procodile proxy service") do 66 | # cli.options.proxy = true 67 | # end 68 | 69 | opts.on( 70 | "--ports PROCESSES", 71 | "Choose ports to allocate to processes" 72 | ) do |processes| 73 | cli.options.port_allocations = processes.split(",") 74 | .each_with_object({} of String => Int32) do |line, hash| 75 | abort "No port specified, e.g. app1:3001,app2:3002" unless line.includes?(":") 76 | 77 | process, port = line.split(":") 78 | hash[process] = port.to_i 79 | end 80 | end 81 | 82 | opts.on( 83 | "-d", 84 | "--dev", 85 | "Run in development mode" 86 | ) do 87 | cli.options.respawn = false 88 | cli.options.foreground = true 89 | cli.options.stop_when_none = true 90 | cli.options.proxy = true 91 | end 92 | end 93 | end 94 | 95 | private def start : Nil 96 | if !supervisor_running? 97 | raise Error.new "Supervisor is not running and cannot be started \ 98 | because --no-supervisor is set" if @options.start_supervisor? == false 99 | 100 | # The supervisor isn't actually running. We need to start it before 101 | # processes can be begin being processed 102 | Supervisor.start(@config, @options) do |supervisor| 103 | supervisor.start_processes( 104 | process_names_from_cli_option, 105 | Supervisor::Options.new(tag: @options.tag) 106 | ) unless @options.start_processes? == false 107 | end 108 | 109 | return 110 | end 111 | 112 | if @options.foreground? 113 | raise Error.new "Cannot be started in the foreground because supervisor already running" 114 | end 115 | 116 | if @options.respawn? 117 | raise Error.new "Cannot disable respawning because supervisor is already running" 118 | end 119 | 120 | if @options.stop_when_none? 121 | raise Error.new "Cannot stop supervisor when none running because supervisor is already running" 122 | end 123 | 124 | if @options.proxy? 125 | raise Error.new "Cannot enable the proxy when the supervisor is running" 126 | end 127 | 128 | instance_configs = ControlClient.run( 129 | @config.sock_path, 130 | "start_processes", 131 | processes: process_names_from_cli_option, 132 | tag: @options.tag, 133 | port_allocations: @options.port_allocations, 134 | ).as Array(Instance::Config) 135 | 136 | if instance_configs.empty? 137 | puts "No processes to start." 138 | else 139 | instance_configs.each do |instance_config| 140 | puts "#{"Started".colorize.green} #{instance_config.description} (PID: #{instance_config.pid})" 141 | end 142 | end 143 | end 144 | end 145 | end 146 | end 147 | -------------------------------------------------------------------------------- /src/procodile/commands/status_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module StatusCommand 4 | macro included 5 | options :status do |opts, cli| 6 | opts.on( 7 | "--json", 8 | "Return the status as a JSON hash" 9 | ) do 10 | cli.options.json = true 11 | end 12 | 13 | opts.on( 14 | "--json-pretty", 15 | "Return the status as a JSON hash printed nicely" 16 | ) do 17 | cli.options.json_pretty = true 18 | end 19 | 20 | opts.on( 21 | "--simple", 22 | "Return overall status" 23 | ) do 24 | cli.options.simple = true 25 | end 26 | end 27 | end 28 | 29 | private def status : Nil 30 | if !supervisor_running? 31 | if @options.simple? 32 | puts "NotRunning || Procodile supervisor isn't running" 33 | 34 | return 35 | else 36 | raise Error.new "Procodile supervisor isn't running" 37 | end 38 | end 39 | 40 | status = ControlClient.run( 41 | @config.sock_path, "status" 42 | ).as ControlClient::ReplyOfStatusCommand 43 | 44 | case @options 45 | when .json? 46 | puts status.to_json 47 | when .json_pretty? 48 | puts status 49 | when .simple? 50 | if status.messages.empty? 51 | message = status.instances.map { |p, i| "#{p}[#{i.size}]" } 52 | 53 | puts "OK || #{message.join(", ")}" 54 | else 55 | message = status.messages.join(", ") 56 | puts "Issues || #{message}" 57 | end 58 | else 59 | print_header(status) 60 | print_processes(status) 61 | end 62 | end 63 | 64 | private def print_header(status : ControlClient::ReplyOfStatusCommand) : Nil 65 | puts "Procodile Version #{status.version.colorize.blue}" 66 | puts "Application Root #{status.root.colorize.blue}" 67 | puts "Supervisor PID #{(status.supervisor["pid"]).to_s.colorize.blue}" 68 | 69 | if (time = status.supervisor["started_at"]) 70 | time = Time.unix(time) 71 | puts "Started #{time.to_s.colorize.blue}" 72 | end 73 | 74 | if !status.environment_variables.empty? 75 | status.environment_variables.each_with_index do |(key, value), index| 76 | if index == 0 77 | print "Environment Vars " 78 | else 79 | print " " 80 | end 81 | print key.colorize.blue 82 | puts " #{value}" 83 | end 84 | end 85 | 86 | unless status.messages.empty? 87 | puts 88 | status.messages.each do |message| 89 | puts " * #{message}".colorize.red 90 | end 91 | end 92 | end 93 | 94 | private def print_processes(status : ControlClient::ReplyOfStatusCommand) : Nil 95 | puts 96 | 97 | status.processes.each_with_index do |process, index| 98 | port = process.proxy_port ? "#{process.proxy_address}:#{process.proxy_port}" : "none" 99 | instances = status.instances[process.name] 100 | 101 | puts unless index == 0 102 | puts "|| #{process.name}".colorize(process.log_color) 103 | puts "#{"||".colorize(process.log_color)} Quantity #{process.quantity}" 104 | puts "#{"||".colorize(process.log_color)} Command #{process.command}" 105 | puts "#{"||".colorize(process.log_color)} Respawning #{process.max_respawns} every #{process.respawn_window} seconds" 106 | puts "#{"||".colorize(process.log_color)} Restart mode #{process.restart_mode}" 107 | puts "#{"||".colorize(process.log_color)} Log path #{process.log_path || "none specified"}" 108 | puts "#{"||".colorize(process.log_color)} Address/Port #{port}" 109 | 110 | if instances.empty? 111 | puts "#{"||".colorize(process.log_color)} No processes running." 112 | else 113 | instances.each do |instance| 114 | print "|| => #{instance.description.ljust(17, ' ')}".colorize(process.log_color) 115 | print instance.status.to_s.ljust(10, ' ') 116 | print " #{formatted_timestamp(instance.started_at).ljust(10, ' ')}" 117 | print " pid:#{instance.pid.to_s.ljust(6, ' ')}" 118 | print " respawns:#{instance.respawns.to_s.ljust(4, ' ')}" 119 | print " port:#{(instance.port || '-').to_s.ljust(6, ' ')}" 120 | print " tag:#{instance.tag || '-'}" 121 | puts 122 | end 123 | end 124 | end 125 | end 126 | 127 | private def formatted_timestamp(timestamp : Int64?) : String 128 | return "" if timestamp.nil? 129 | 130 | timestamp = Time.unix(timestamp) 131 | 132 | if timestamp > 1.day.ago 133 | timestamp.to_s("%H:%M") 134 | else 135 | timestamp.to_s("%d/%m/%Y") 136 | end 137 | end 138 | end 139 | end 140 | end 141 | -------------------------------------------------------------------------------- /src/procodile/commands/stop_command.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class CLI 3 | module StopCommand 4 | macro included 5 | options :stop do |opts, cli| 6 | opts.on( 7 | "-p", 8 | "--processes a,b,c", 9 | "Only stop the listed processes or process types" 10 | ) do |processes| 11 | cli.options.processes = processes 12 | end 13 | 14 | opts.on( 15 | "-s", 16 | "--stop-supervisor", 17 | "Stop the supervisor process when all processes are stopped" 18 | ) do 19 | cli.options.stop_supervisor = true 20 | end 21 | 22 | opts.on( 23 | "--wait", 24 | "Wait until supervisor has stopped before exiting" 25 | ) do 26 | cli.options.wait_until_supervisor_stopped = true 27 | end 28 | end 29 | end 30 | 31 | private def stop : Nil 32 | raise Error.new "Procodile supervisor isn't running" unless supervisor_running? 33 | 34 | instances = ControlClient.run( 35 | @config.sock_path, 36 | "stop", 37 | processes: process_names_from_cli_option, 38 | stop_supervisor: @options.stop_supervisor?, 39 | ).as(Array(Instance::Config)) 40 | 41 | if instances.empty? 42 | puts "No processes were stopped." 43 | else 44 | instances.each do |instance| 45 | puts "#{"Stopped".colorize.red} #{instance.description} (PID: #{instance.pid})" 46 | end 47 | end 48 | 49 | puts "Supervisor will be stopped when processes are stopped." if @options.stop_supervisor? 50 | 51 | if @options.wait_until_supervisor_stopped? 52 | puts "Waiting for supervisor to stop..." 53 | 54 | loop do 55 | sleep 1.second 56 | 57 | next if supervisor_running? 58 | 59 | abort "Supervisor has stopped", status: 0 60 | end 61 | end 62 | end 63 | end 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /src/procodile/config.cr: -------------------------------------------------------------------------------- 1 | require "./process" 2 | 3 | module Procodile 4 | class Config 5 | COLORS = [ 6 | Colorize::ColorANSI::Magenta, # 35 紫 7 | Colorize::ColorANSI::Red, # 31 红 8 | Colorize::ColorANSI::Cyan, # 36 青 9 | Colorize::ColorANSI::Green, # 32 绿 10 | Colorize::ColorANSI::Yellow, # 33 橘 11 | Colorize::ColorANSI::Blue, # 34 蓝 12 | ] 13 | 14 | getter process_list : Hash(String, String) { load_process_list_from_file } 15 | getter processes : Hash(String, Procodile::Process) { {} of String => Procodile::Process } 16 | 17 | getter options : Config::Option { load_options_from_file } 18 | getter local_options : Config::Option { load_local_options_from_file } 19 | getter process_options : Hash(String, Procodile::Process::Option) do 20 | options.processes || {} of String => Procodile::Process::Option 21 | end 22 | getter local_process_options : Hash(String, Procodile::Process::Option) do 23 | local_options.processes || {} of String => Procodile::Process::Option 24 | end 25 | getter app_name : String do 26 | local_options.app_name || options.app_name || "Procodile" 27 | end 28 | getter loaded_at : Time? 29 | getter root : String 30 | getter environment_variables : Hash(String, String) do 31 | option_env = options.env || {} of String => String 32 | local_option_env = local_options.env || {} of String => String 33 | 34 | option_env.merge(local_option_env) 35 | end 36 | 37 | def initialize(@root : String, @procfile : String? = nil) 38 | unless File.file?(procfile_path) 39 | raise Error.new("Procfile not found at #{procfile_path}") 40 | end 41 | 42 | # We need to check to see if the local or options 43 | # configuration will override the root that we've been given. 44 | # If they do, we can throw away any reference to the one that the 45 | # configuration was initialized with and start using that immediately. 46 | if (new_root = local_options.root || options.root) 47 | @root = new_root 48 | end 49 | 50 | FileUtils.mkdir_p(pid_root) 51 | 52 | @processes = process_list.each_with_index.each_with_object( 53 | {} of String => Procodile::Process 54 | ) do |(h, index), hash| 55 | name = h[0] 56 | command = h[1] 57 | 58 | hash[name] = create_process(name, command, COLORS[index.divmod(COLORS.size)[1]]) 59 | end 60 | 61 | @loaded_at = Time.local 62 | end 63 | 64 | def reload : Nil 65 | @options = nil 66 | @local_options = nil 67 | 68 | @process_options = nil 69 | @local_process_options = nil 70 | 71 | @process_list = nil 72 | @environment_variables = nil 73 | @loaded_at = nil 74 | 75 | if (processes = @processes) 76 | process_list.each do |name, command| 77 | if (process = processes[name]?) 78 | process.removed = false 79 | 80 | # This command is already in our list. Add it. 81 | if process.command != command 82 | process.command = command 83 | Procodile.log nil, "system", "#{name} command has changed. Updated." 84 | end 85 | 86 | process.options = options_for_process(name) 87 | else 88 | Procodile.log nil, "system", "#{name} has been added to the Procfile. Adding it." 89 | processes[name] = create_process(name, command, COLORS[processes.size.divmod(COLORS.size)[1]]) 90 | end 91 | end 92 | 93 | removed_processes_name = processes.keys - process_list.keys 94 | 95 | removed_processes_name.each do |process_name| 96 | if (p = processes[process_name]) 97 | p.removed = true 98 | processes.delete(process_name) 99 | Procodile.log nil, "system", "#{process_name} has been removed in the \ 100 | Procfile. It will be removed when it is stopped." 101 | end 102 | end 103 | end 104 | 105 | @loaded_at = Time.local 106 | end 107 | 108 | def user : String? 109 | local_options.user || options.user 110 | end 111 | 112 | def console_command : String? 113 | local_options.console_command || options.console_command 114 | end 115 | 116 | def exec_prefix : String? 117 | local_options.exec_prefix || options.exec_prefix 118 | end 119 | 120 | def options_for_process(name : String) : Procodile::Process::Option 121 | po = process_options[name]? || Procodile::Process::Option.new 122 | local_po = local_process_options[name]? || Procodile::Process::Option.new 123 | 124 | po.merge(local_po) 125 | end 126 | 127 | def pid_root : String? 128 | File.expand_path(local_options.pid_root || options.pid_root || "pids", self.root) 129 | end 130 | 131 | def supervisor_pid_path : String 132 | File.join(pid_root, "procodile.pid") 133 | end 134 | 135 | def log_path : String 136 | log_path = local_options.log_path || options.log_path 137 | 138 | if log_path 139 | File.expand_path(log_path, self.root) 140 | elsif log_path.nil? && (log_root = self.log_root) 141 | File.join(log_root, "procodile.log") 142 | else 143 | File.expand_path("procodile.log", self.root) 144 | end 145 | end 146 | 147 | def log_root : String? 148 | log_root = local_options.log_root || options.log_root 149 | 150 | File.expand_path(log_root, self.root) if log_root 151 | end 152 | 153 | def sock_path : String 154 | File.join(pid_root, "procodile.sock") 155 | end 156 | 157 | def procfile_path : String 158 | @procfile || File.join(self.root, "Procfile") 159 | end 160 | 161 | def options_path : String 162 | "#{procfile_path}.options" 163 | end 164 | 165 | def local_options_path : String 166 | "#{procfile_path}.local" 167 | end 168 | 169 | private def create_process( 170 | name : String, 171 | command : String, 172 | log_color : Colorize::ColorANSI, 173 | ) : Procodile::Process 174 | process = Procodile::Process.new(self, name, command, options_for_process(name)) 175 | process.log_color = log_color 176 | process 177 | end 178 | 179 | private def load_process_list_from_file : Hash(String, String) 180 | Hash(String, String).from_yaml(File.read(procfile_path)) 181 | end 182 | 183 | private def load_options_from_file : Config::Option 184 | if File.exists?(options_path) 185 | Config::Option.from_yaml(File.read(options_path)) 186 | else 187 | Config::Option.new 188 | end 189 | end 190 | 191 | private def load_local_options_from_file : Config::Option 192 | if File.exists?(local_options_path) 193 | Config::Option.from_yaml(File.read(local_options_path)) 194 | else 195 | Config::Option.new 196 | end 197 | end 198 | end 199 | 200 | struct Config::Option 201 | include YAML::Serializable 202 | 203 | property app_name : String? 204 | property root : String? 205 | property procfile : String? 206 | property pid_root : String? 207 | property log_path : String? 208 | property log_root : String? 209 | property user : String? 210 | property console_command : String? 211 | property exec_prefix : String? 212 | property env : Hash(String, String)? 213 | property processes : Hash(String, Procodile::Process::Option)? 214 | property app_id : Procodile::Process::Option? 215 | 216 | def initialize 217 | end 218 | end 219 | 220 | struct Config::GlobalOption 221 | include YAML::Serializable 222 | 223 | property name : String 224 | property root : String 225 | property procfile : String? 226 | end 227 | end 228 | -------------------------------------------------------------------------------- /src/procodile/control_client.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class ControlClient 3 | alias SocketResponse = Array(Instance::Config) | 4 | Array(Tuple(Instance::Config?, Instance::Config?)) | 5 | NamedTuple(started: Array(Instance::Config), stopped: Array(Instance::Config)) | 6 | ControlClient::ReplyOfStatusCommand | 7 | Bool 8 | 9 | def self.run(sock_path : String, command : String, **options) : SocketResponse 10 | socket = self.new(sock_path) 11 | socket.run(command, **options) 12 | ensure 13 | socket.try &.disconnect 14 | end 15 | 16 | def initialize(sock_path : String) 17 | @socket = UNIXSocket.new(sock_path) 18 | end 19 | 20 | def run(command : String, **options) : SocketResponse 21 | @socket.puts("#{command} #{options.to_json}") 22 | 23 | if (data = @socket.gets) 24 | code, reply = data.strip.split(/\s+/, 2) 25 | 26 | if code.to_i == 200 && reply && !reply.empty? 27 | case command 28 | when "start_processes", "stop" 29 | Array(Instance::Config).from_json(reply) 30 | when "restart" 31 | Array(Tuple(Instance::Config?, Instance::Config?)).from_json(reply) 32 | when "check_concurrency" 33 | NamedTuple( 34 | started: Array(Instance::Config), 35 | stopped: Array(Instance::Config)).from_json(reply) 36 | when "status" 37 | ControlClient::ReplyOfStatusCommand.from_json(reply) 38 | else # e.g. stop, reload command 39 | true 40 | end 41 | else 42 | raise Error.new "Error from control server: #{code}: (#{reply.inspect})" 43 | end 44 | else 45 | raise Error.new "Control server disconnected. data: #{data.inspect}" 46 | end 47 | end 48 | 49 | def disconnect : Nil 50 | @socket.try &.close 51 | end 52 | end 53 | 54 | struct ControlClient::ProcessStatus 55 | include JSON::Serializable 56 | 57 | getter name : String 58 | getter log_color : Colorize::ColorANSI 59 | getter quantity : Int32 60 | getter max_respawns : Int32 61 | getter respawn_window : Int32 62 | getter command : String 63 | getter restart_mode : Signal | String | Nil 64 | getter log_path : String? 65 | getter removed : Bool 66 | getter proxy_port : Int32? 67 | getter proxy_address : String? 68 | 69 | def initialize( 70 | @name : String, 71 | @log_color : Colorize::ColorANSI, 72 | @quantity : Int32, 73 | @max_respawns : Int32, 74 | @respawn_window : Int32, 75 | @command : String, 76 | @restart_mode : Signal | String | Nil, 77 | @log_path : String?, 78 | @removed : Bool, 79 | @proxy_port : Int32?, 80 | @proxy_address : String?, 81 | ) 82 | end 83 | end 84 | 85 | # Reply of `procodile status` 86 | struct ControlClient::ReplyOfStatusCommand 87 | include JSON::Serializable 88 | 89 | getter version : String 90 | getter messages : Array(Supervisor::Message) 91 | getter root : String 92 | getter app_name : String 93 | getter supervisor : NamedTuple(started_at: Int64?, pid: Int64) 94 | getter instances : Hash(String, Array(Instance::Config)) 95 | getter processes : Array(ControlClient::ProcessStatus) 96 | getter environment_variables : Hash(String, String) 97 | getter procfile_path : String 98 | getter options_path : String 99 | getter local_options_path : String 100 | getter sock_path : String 101 | getter supervisor_pid_path : String 102 | getter pid_root : String 103 | getter loaded_at : Int64? 104 | getter log_root : String? 105 | 106 | def initialize( 107 | @version : String, 108 | @messages : Array(Supervisor::Message), 109 | @root : String, 110 | @app_name : String, 111 | @supervisor : NamedTuple(started_at: Int64?, pid: Int64), 112 | @instances : Hash(String, Array(Instance::Config)), 113 | @processes : Array(ControlClient::ProcessStatus), 114 | @environment_variables : Hash(String, String), 115 | @procfile_path : String, 116 | @options_path : String, 117 | @local_options_path : String, 118 | @sock_path : String, 119 | @supervisor_pid_path : String, 120 | @pid_root : String, 121 | @loaded_at : Int64?, 122 | @log_root : String?, 123 | ) 124 | end 125 | end 126 | end 127 | -------------------------------------------------------------------------------- /src/procodile/control_server.cr: -------------------------------------------------------------------------------- 1 | require "./control_session" 2 | 3 | module Procodile 4 | class ControlServer 5 | def self.start(supervisor : Supervisor) : Nil 6 | spawn do 7 | socket = self.new(supervisor) 8 | socket.listen 9 | end 10 | end 11 | 12 | def initialize(@supervisor : Supervisor) 13 | end 14 | 15 | def listen : Nil 16 | sock_path = @supervisor.config.sock_path 17 | server = UNIXServer.new(sock_path) 18 | 19 | Procodile.log nil, "control", "Listening at #{sock_path}" 20 | 21 | while (client = server.accept) 22 | session = ControlSession.new(@supervisor, client) 23 | 24 | spawn handle_client(session, client) 25 | end 26 | ensure 27 | FileUtils.rm_rf(sock_path) if sock_path 28 | end 29 | 30 | private def handle_client(session : ControlSession, client : UNIXSocket) : Nil 31 | while (line = client.gets) 32 | if (response = session.receive_data(line.strip)) 33 | client.puts response 34 | end 35 | end 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /src/procodile/control_session.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class ControlSession 3 | def initialize(@supervisor : Supervisor, @client : UNIXSocket) 4 | end 5 | 6 | private def start_processes(options : ControlSession::Options) : String 7 | if (ports = options.port_allocations) 8 | if (run_options_ports = @supervisor.run_options.port_allocations) 9 | run_options_ports.merge!(ports) 10 | else 11 | @supervisor.run_options.port_allocations = ports 12 | end 13 | end 14 | 15 | instances = @supervisor.start_processes( 16 | options.processes, 17 | Supervisor::Options.new(tag: options.tag) 18 | ) 19 | 20 | "200 #{instances.map(&.to_struct).to_json}" 21 | end 22 | 23 | private def stop(options : ControlSession::Options) : String 24 | instances = @supervisor.stop( 25 | Supervisor::Options.new( 26 | processes: options.processes, 27 | stop_supervisor: options.stop_supervisor 28 | ) 29 | ) 30 | 31 | "200 #{instances.map(&.to_struct).to_json}" 32 | end 33 | 34 | private def restart(options : ControlSession::Options) : String 35 | instances = @supervisor.restart( 36 | Supervisor::Options.new( 37 | processes: options.processes, 38 | tag: options.tag 39 | ) 40 | ) 41 | 42 | "200 " + instances.map { |a| a.map { |i| i ? i.to_struct : nil } }.to_json 43 | end 44 | 45 | private def reload_config(options : ControlSession::Options) : String 46 | @supervisor.reload_config 47 | 48 | "200 []" 49 | end 50 | 51 | private def check_concurrency(options : ControlSession::Options) : String 52 | result = @supervisor.check_concurrency( 53 | Supervisor::Options.new( 54 | reload: options.reload 55 | ) 56 | ) 57 | 58 | result = result.transform_values { |instances, _type| instances.map(&.to_struct) } 59 | 60 | "200 #{result.to_json}" 61 | end 62 | 63 | private def status(options : ControlSession::Options) : String 64 | instances = {} of String => Array(Instance::Config) 65 | 66 | @supervisor.processes.each do |process, process_instances| 67 | instances[process.name] = [] of Instance::Config 68 | process_instances.each do |instance| 69 | instances[process.name] << instance.to_struct 70 | end 71 | end 72 | 73 | processes = @supervisor.processes.keys.map(&.to_struct) 74 | 75 | loaded_at = @supervisor.config.loaded_at 76 | 77 | result = ControlClient::ReplyOfStatusCommand.new( 78 | version: VERSION, 79 | messages: @supervisor.messages, 80 | root: @supervisor.config.root, 81 | app_name: @supervisor.config.app_name, 82 | supervisor: @supervisor.to_hash, 83 | instances: instances, 84 | processes: processes, 85 | environment_variables: @supervisor.config.environment_variables, 86 | procfile_path: @supervisor.config.procfile_path, 87 | options_path: @supervisor.config.options_path, 88 | local_options_path: @supervisor.config.local_options_path, 89 | sock_path: @supervisor.config.sock_path, 90 | log_root: @supervisor.config.log_root, 91 | supervisor_pid_path: @supervisor.config.supervisor_pid_path, 92 | pid_root: @supervisor.config.pid_root, 93 | loaded_at: loaded_at ? loaded_at.to_unix : nil, 94 | ) 95 | 96 | "200 #{result.to_json}" 97 | end 98 | 99 | {% begin %} 100 | def receive_data(data : String) : String 101 | command, session_data = data.split(/\s+/, 2) 102 | options = ControlSession::Options.from_json(session_data) 103 | 104 | callable = {} of String => Proc(ControlSession::Options, String) 105 | 106 | {% for e in @type.methods %} 107 | # It's interest, @type.methods not include current defined #receive_data method. 108 | {% if e.name.stringify != "initialize" %} 109 | callable[{{ e.name.stringify }}] = ->{{ e.name }}(ControlSession::Options) 110 | {% end %} 111 | {% end %} 112 | 113 | if callable[command]? 114 | begin 115 | callable[command].call(options) 116 | rescue e : Error 117 | Procodile.log nil, "control", "Error: #{e.message}".colorize.red.to_s 118 | "500 #{e.message}" 119 | end 120 | else 121 | "404 Invaid command" 122 | end 123 | end 124 | {% end %} 125 | end 126 | 127 | struct ControlSession::Options 128 | include JSON::Serializable 129 | 130 | getter processes : Array(String)? = [] of String 131 | getter tag : String? 132 | getter port_allocations : Hash(String, Int32)? 133 | getter reload : Bool? 134 | getter stop_supervisor : Bool? 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /src/procodile/core_ext/process.cr: -------------------------------------------------------------------------------- 1 | class ::Process 2 | # override ::Process.fork in stdlib for suppress the warning message. 3 | def self.fork(&) : ::Process 4 | new Crystal::System::Process.fork { yield } 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /src/procodile/instance.cr: -------------------------------------------------------------------------------- 1 | require "./start_supervisor" 2 | require "./supervisor" 3 | 4 | module Procodile 5 | class Instance 6 | @stopping_at : Time? 7 | @started_at : Time? 8 | @failed_at : Time? 9 | 10 | property port : Int32? 11 | property process : Procodile::Process 12 | property pid : Int64? 13 | 14 | getter tag : String? 15 | getter id : Int32 16 | getter? stopped : Bool 17 | 18 | # Return a description for this instance 19 | getter description : String { "#{@process.name}.#{@id}" } 20 | 21 | def initialize(@supervisor : Supervisor, @process : Procodile::Process, @id : Int32) 22 | @respawns = 0 23 | @stopped = false 24 | end 25 | 26 | # 27 | # Start a new instance of this process 28 | # 29 | def start : Nil 30 | if stopping? 31 | Procodile.log(@process.log_color, description, "Process is stopped/stopping \ 32 | therefore cannot be started again.") 33 | 34 | return 35 | end 36 | 37 | update_pid 38 | 39 | if running? 40 | Procodile.log(@process.log_color, description, "Already running with PID #{@pid}") 41 | 42 | return 43 | end 44 | 45 | port_allocations = @supervisor.run_options.port_allocations 46 | 47 | if port_allocations && (chosen_port = port_allocations[@process.name]?) 48 | if chosen_port == 0 49 | allocate_port 50 | else 51 | @port = chosen_port 52 | Procodile.log(@process.log_color, description, "Assigned #{chosen_port} to process") 53 | end 54 | elsif (proposed_port = @process.allocate_port_from) && @process.restart_mode != "start-term" 55 | # Allocate ports to this process sequentially from the starting port 56 | process = @supervisor.processes[@process]? 57 | allocated_ports = process ? process.select(&.running?).map(&.port) : [] of Int32 58 | 59 | while !@port 60 | @port = proposed_port unless allocated_ports.includes?(proposed_port) 61 | proposed_port += 1 62 | end 63 | end 64 | 65 | if self.process.log_path && @supervisor.run_options.force_single_log? != true 66 | FileUtils.mkdir_p(File.dirname(self.process.log_path)) 67 | log_destination = File.open(self.process.log_path, "a") 68 | io = nil 69 | else 70 | reader, writer = IO.pipe 71 | log_destination = writer 72 | io = reader 73 | end 74 | 75 | @tag = @supervisor.tag.dup if @supervisor.tag 76 | 77 | Dir.cd(@process.config.root) 78 | 79 | commands = @process.command.split(" ") 80 | 81 | process = ::Process.new( 82 | command: commands[0], 83 | args: commands[1..], 84 | env: environment_variables, 85 | output: log_destination, 86 | error: log_destination 87 | ) 88 | 89 | spawn { process.wait } 90 | 91 | @pid = process.pid 92 | 93 | log_destination.close 94 | 95 | File.write(pid_file_path, "#{@pid}\n") 96 | 97 | @supervisor.add_instance(self, io) 98 | 99 | tag = @tag ? " (tagged with #{@tag})" : "" 100 | 101 | Procodile.log(@process.log_color, description, "Started with PID #{@pid}#{tag}") 102 | 103 | if self.process.log_path && io.nil? 104 | Procodile.log(@process.log_color, description, "Logging to #{self.process.log_path}") 105 | end 106 | 107 | @started_at = Time.local 108 | end 109 | 110 | # 111 | # Send this signal the signal to stop and mark the instance in a state that 112 | # tells us that we want it to be stopped. 113 | # 114 | def stop : Nil 115 | @stopping_at = Time.local 116 | 117 | update_pid 118 | 119 | if running? 120 | Procodile.log(@process.log_color, description, "Sending #{@process.term_signal} to #{@pid}") 121 | 122 | ::Process.signal(@process.term_signal, @pid.not_nil!) 123 | else 124 | Procodile.log(@process.log_color, description, "Process already stopped") 125 | end 126 | end 127 | 128 | # 129 | # Retarts the process using the appropriate method from the process configuration 130 | # 131 | # Why would this return self here? 132 | def restart(wg : WaitGroup) : self? 133 | restart_mode = @process.restart_mode 134 | 135 | Procodile.log(@process.log_color, description, "Restarting using #{restart_mode} mode") 136 | 137 | update_pid 138 | 139 | case restart_mode 140 | when Signal::USR1, Signal::USR2 141 | if running? 142 | ::Process.signal(restart_mode.as(Signal), @pid.not_nil!) 143 | 144 | @tag = @supervisor.tag if @supervisor.tag 145 | Procodile.log(@process.log_color, description, "Sent #{restart_mode.to_s.upcase} \ 146 | signal to process #{@pid}") 147 | else 148 | Procodile.log(@process.log_color, description, "Process not running already. \ 149 | Starting it.") 150 | on_stop 151 | new_instance = @process.create_instance(@supervisor) 152 | new_instance.port = self.port 153 | new_instance.start 154 | end 155 | 156 | self 157 | when "start-term" 158 | new_instance = @process.create_instance(@supervisor) 159 | new_instance.start 160 | 161 | stop 162 | 163 | new_instance 164 | when "term-start" 165 | wg.add 166 | 167 | stop 168 | 169 | new_instance = @process.create_instance(@supervisor) 170 | new_instance.port = self.port 171 | 172 | spawn do 173 | while running? 174 | sleep 0.5.seconds 175 | end 176 | 177 | @supervisor.remove_instance(self) 178 | 179 | new_instance.start 180 | ensure 181 | wg.done 182 | end 183 | 184 | new_instance 185 | end 186 | end 187 | 188 | # 189 | # Check the status of this process and handle as appropriate. 190 | # 191 | def check : Nil 192 | return if failed? 193 | 194 | # Everything is OK. The process is running. 195 | return true if running? 196 | 197 | # If the process isn't running any more, update the PID in our memory from 198 | # the file in case the process has changed itself. 199 | return check if update_pid 200 | 201 | if @supervisor.allow_respawning? 202 | if can_respawn? 203 | Procodile.log(@process.log_color, description, "Process has stopped. \ 204 | Respawning...") 205 | start 206 | add_respawn 207 | elsif respawns >= @process.max_respawns 208 | Procodile.log( 209 | @process.log_color, 210 | description, 211 | "Warning:".colorize.light_gray.on_red.to_s + 212 | " this process has been respawned #{respawns} times and \ 213 | keeps dying.".colorize.red.to_s 214 | ) 215 | 216 | Procodile.log( 217 | @process.log_color, 218 | description, 219 | "It will not be respawned automatically any longer and will no longer \ 220 | be managed.".colorize.red.to_s 221 | ) 222 | 223 | @failed_at = Time.local 224 | 225 | tidy 226 | end 227 | else 228 | Procodile.log(@process.log_color, description, "Process has stopped. \ 229 | Respawning not available.") 230 | 231 | @failed_at = Time.local 232 | 233 | tidy 234 | end 235 | end 236 | 237 | # 238 | # Return this instance as a hash 239 | # 240 | def to_struct : Instance::Config 241 | started_at = @started_at 242 | 243 | Instance::Config.new( 244 | description: self.description, 245 | pid: self.pid, 246 | respawns: self.respawns, 247 | status: self.status, 248 | started_at: started_at ? started_at.to_unix : nil, 249 | tag: self.tag, 250 | port: @port, 251 | foreground: @supervisor.run_use_foreground? 252 | ) 253 | end 254 | 255 | # 256 | # Return the status of this instance 257 | # 258 | def status : Instance::Status 259 | if stopped? 260 | Instance::Status::Stopped 261 | elsif stopping? 262 | Instance::Status::Stopping 263 | elsif running? 264 | Instance::Status::Running 265 | elsif failed? 266 | Instance::Status::Failed 267 | else 268 | Instance::Status::Unknown 269 | end 270 | end 271 | 272 | # 273 | # Should this process be running? 274 | # 275 | def should_be_running? : Bool 276 | !(stopped? || stopping?) 277 | end 278 | 279 | # 280 | # Is this process running? Pass an option to check the given PID instead of the instance 281 | # 282 | def running? : Bool 283 | if (pid = @pid) 284 | ::Process.pgid(pid) ? true : false 285 | else 286 | false 287 | end 288 | rescue RuntimeError 289 | false 290 | end 291 | 292 | # 293 | # Is this instance supposed to be stopping/be stopped? 294 | # 295 | def stopping? : Bool 296 | @stopping_at ? true : false 297 | end 298 | 299 | # 300 | # Has this failed? 301 | # 302 | def failed? : Bool 303 | @failed_at ? true : false 304 | end 305 | 306 | # 307 | # A method that will be called when this instance has been stopped and it isn't going to be 308 | # started again 309 | # 310 | def on_stop : Nil 311 | @started_at = nil 312 | @stopped = true 313 | 314 | tidy 315 | end 316 | 317 | # 318 | # Find a port number for this instance to listen on. We just check that nothing is already listening on it. 319 | # The process is expected to take it straight away if it wants it. 320 | # 321 | private def allocate_port(max_attempts : Int32 = 10) : Nil 322 | attempts = 0 323 | 324 | until @port 325 | attempts += 1 326 | possible_port = rand(20000..29999) 327 | 328 | if self.port_available?(possible_port) 329 | Procodile.log(@process.log_color, description, "Allocated port as #{possible_port}") 330 | @port = possible_port 331 | elsif attempts >= max_attempts 332 | raise Error.new "Couldn't allocate port for #{@process.name}" 333 | end 334 | end 335 | end 336 | 337 | # 338 | # Is the given port available? 339 | # 340 | private def port_available?(port : Int32) : Bool 341 | case @process.network_protocol 342 | when "tcp" 343 | server = TCPServer.new("127.0.0.1", port) 344 | server.close 345 | true 346 | when "udp" 347 | server = UDPSocket.new 348 | server.bind("127.0.0.1", port) 349 | server.close 350 | true 351 | else 352 | raise Error.new "Invalid network_protocol '#{@process.network_protocol}'" 353 | end 354 | rescue Socket::BindError 355 | false 356 | end 357 | 358 | # 359 | # Tidy up when this process isn't needed any more 360 | # 361 | private def tidy : Nil 362 | FileUtils.rm_rf(self.pid_file_path) 363 | Procodile.log(@process.log_color, description, "Removed PID file") 364 | end 365 | 366 | # 367 | # Increment the counter of respawns for this process 368 | # 369 | private def add_respawn : Int32 370 | last_respawn = @last_respawn 371 | 372 | if last_respawn && last_respawn < (Time.local - @process.respawn_window.seconds) 373 | @respawns = 1 374 | else 375 | @last_respawn = Time.local 376 | @respawns += 1 377 | end 378 | end 379 | 380 | # 381 | # Return the number of times this process has been respawned in the last hour 382 | # 383 | private def respawns : Int32 384 | last_respawn = @last_respawn 385 | 386 | if @respawns.nil? || last_respawn.nil? || last_respawn < @process.respawn_window.seconds.ago 387 | 0 388 | else 389 | @respawns 390 | end 391 | end 392 | 393 | # 394 | # Can this process be respawned if needed? 395 | # 396 | private def can_respawn? : Bool 397 | !stopping? && (respawns + 1) <= @process.max_respawns 398 | end 399 | 400 | # 401 | # Return an array of environment variables that should be set 402 | # 403 | private def environment_variables : Hash(String, String) 404 | vars = @process.environment_variables.merge({ 405 | "PROC_NAME" => self.description, 406 | "PID_FILE" => self.pid_file_path, 407 | "APP_ROOT" => @process.config.root, 408 | }) 409 | vars["PORT"] = @port.to_s if @port 410 | 411 | vars 412 | end 413 | 414 | # 415 | # Update the locally cached PID from that stored on the file system. 416 | # 417 | private def update_pid : Bool 418 | pid_from_file = self.pid_from_file 419 | if pid_from_file && pid_from_file != @pid 420 | @pid = pid_from_file 421 | @started_at = File.info(self.pid_file_path).modification_time 422 | 423 | Procodile.log(@process.log_color, description, "PID file changed. Updated pid to #{@pid}") 424 | true 425 | else 426 | false 427 | end 428 | end 429 | 430 | # 431 | # Return the path to this instance's PID file 432 | # 433 | private def pid_file_path : String 434 | File.join(@process.config.pid_root, "#{description}.pid") 435 | end 436 | 437 | # 438 | # Return the PID that is in the instances process PID file 439 | # 440 | private def pid_from_file : Int64? 441 | if File.exists?(pid_file_path) 442 | pid = File.read(pid_file_path) 443 | pid.blank? ? nil : pid.strip.to_i64 444 | end 445 | end 446 | end 447 | 448 | enum Instance::Status 449 | Unknown 450 | Stopped 451 | Stopping 452 | Running 453 | Failed 454 | end 455 | 456 | struct Instance::Config 457 | include JSON::Serializable 458 | 459 | getter description : String 460 | getter pid : Int64? 461 | getter respawns : Int32 462 | getter status : Instance::Status 463 | getter started_at : Int64? 464 | getter tag : String? 465 | getter port : Int32? 466 | getter? foreground : Bool 467 | 468 | def initialize( 469 | @description : String, 470 | @pid : Int64?, 471 | @respawns : Int32, 472 | @status : Instance::Status, 473 | @started_at : Int64?, 474 | @tag : String?, 475 | @port : Int32?, 476 | 477 | # foreground is used for supervisor, but add here for simplicity communication 478 | @foreground : Bool = false, 479 | ) 480 | end 481 | end 482 | end 483 | -------------------------------------------------------------------------------- /src/procodile/logger.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | private class_getter logger_mutex : Mutex { Mutex.new } 3 | 4 | def self.log(color : Colorize::ColorANSI?, name : String, text : String) : Nil 5 | color = Colorize::ColorANSI::Default if color.nil? 6 | 7 | logger_mutex.synchronize do 8 | text.each_line do |message| 9 | STDOUT << "#{Time.local.to_s("%H:%M:%S")} #{name.ljust(18, ' ')} | ".colorize(color) 10 | STDOUT << message 11 | STDOUT << "\n" 12 | end 13 | 14 | STDOUT.flush 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /src/procodile/process.cr: -------------------------------------------------------------------------------- 1 | require "./control_client" 2 | require "./instance" 3 | 4 | module Procodile 5 | class Process 6 | @@mutex = Mutex.new 7 | @instance_index : Int32 = 0 8 | 9 | getter config : Config 10 | getter name : String 11 | 12 | property command : String 13 | property options : Procodile::Process::Option 14 | property log_color : Colorize::ColorANSI = Colorize::ColorANSI::Default 15 | property removed : Bool = false 16 | 17 | delegate allocate_port_from, proxy_port, to: @options 18 | 19 | def initialize( 20 | @config : Config, 21 | @name : String, 22 | @command : String, 23 | @options : Procodile::Process::Option = Procodile::Process::Option.new, 24 | ) 25 | end 26 | 27 | # 28 | # Return all environment variables for this process 29 | # 30 | def environment_variables : Hash(String, String) 31 | global_variables = @config.environment_variables 32 | 33 | process_vars = if (process = @config.process_options[@name]?) 34 | process.env || {} of String => String 35 | else 36 | {} of String => String 37 | end 38 | 39 | process_local_vars = if (local_process = @config.local_process_options[@name]?) 40 | local_process.env || {} of String => String 41 | else 42 | {} of String => String 43 | end 44 | 45 | global_variables.merge(process_vars.merge(process_local_vars)) 46 | end 47 | 48 | # 49 | # How many instances of this process should be started 50 | # 51 | def quantity : Int32 52 | @options.quantity || 1 53 | end 54 | 55 | # 56 | # The maximum number of times this process can be respawned in the given period 57 | # 58 | def max_respawns : Int32 59 | @options.max_respawns || 5 60 | end 61 | 62 | # 63 | # The respawn window. One hour by default. 64 | # 65 | def respawn_window : Int32 66 | @options.respawn_window || 3600 67 | end 68 | 69 | # 70 | # Return the path where log output for this process should be written to. If 71 | # none, output will be written to the supervisor log. 72 | # 73 | def log_path : String 74 | log_path = @options.log_path 75 | 76 | log_path ? File.expand_path(log_path, @config.root) : default_log_path 77 | end 78 | 79 | # 80 | # Return the log path for this process if no log path is provided and split logs 81 | # is enabled 82 | # 83 | def default_log_path : String 84 | if (lr = @config.log_root) 85 | File.join(lr, default_log_file_name) 86 | else 87 | File.join(@config.root, default_log_file_name) 88 | end 89 | end 90 | 91 | # 92 | # Return the defualt log file name 93 | # 94 | def default_log_file_name : String 95 | @options.log_file_name || "#{@name}.log" 96 | end 97 | 98 | # 99 | # Return the signal to send to terminate the process 100 | # 101 | def term_signal : Signal 102 | @options.term_signal || Signal::TERM 103 | end 104 | 105 | # 106 | # Defines how this process should be restarted 107 | # 108 | # start-term = start new instances and send term to children 109 | # usr1 = just send a usr1 signal to the current instance 110 | # usr2 = just send a usr2 signal to the current instance 111 | # term-start = stop the old instances, when no longer running, start a new one 112 | # 113 | def restart_mode : Signal | String 114 | @options.restart_mode || "term-start" 115 | end 116 | 117 | # 118 | # Return the network protocol for this process 119 | # 120 | def network_protocol : String 121 | @options.network_protocol || "tcp" 122 | end 123 | 124 | # 125 | # Is this process enabled for proxying? 126 | # 127 | def proxy? : Bool 128 | !!@options.proxy_port 129 | end 130 | 131 | # 132 | # Return the port for the proxy to listen on for this process type 133 | # 134 | def proxy_address : String? 135 | proxy? ? @options.proxy_address || "127.0.0.1" : nil 136 | end 137 | 138 | # 139 | # Generate an array of new instances for this process (based on its quantity) 140 | # 141 | def generate_instances( 142 | supervisor : Supervisor, 143 | quantity : Int32 = self.quantity, 144 | ) : Array(Instance) 145 | Array.new(quantity) { create_instance(supervisor) } 146 | end 147 | 148 | # 149 | # Create a new instance 150 | # 151 | def create_instance(supervisor : Supervisor) : Instance 152 | # supervisor is A Supervisor object like this: 153 | # { 154 | # :started_at => 1667297292, 155 | # :pid => 410794, 156 | # } 157 | 158 | Instance.new(supervisor, self, instance_id) 159 | end 160 | 161 | # 162 | # Return a struct 163 | # 164 | def to_struct : ControlClient::ProcessStatus 165 | ControlClient::ProcessStatus.new( 166 | name: self.name, 167 | log_color: self.log_color, 168 | quantity: self.quantity, 169 | max_respawns: self.max_respawns, 170 | respawn_window: self.respawn_window, 171 | command: self.command, 172 | restart_mode: self.restart_mode, 173 | log_path: self.log_path, 174 | removed: self.removed ? true : false, 175 | proxy_port: proxy_port, 176 | proxy_address: proxy_address, 177 | ) 178 | end 179 | 180 | # 181 | # Is the given quantity suitable for this process? 182 | # 183 | def correct_quantity?(quantity : Int32) : Bool 184 | if self.restart_mode == "start-term" 185 | quantity >= self.quantity 186 | else 187 | self.quantity == quantity 188 | end 189 | end 190 | 191 | # 192 | # Increase the instance index and return 193 | # 194 | private def instance_id : Int32 195 | @@mutex.synchronize do 196 | @instance_index = 0 if @instance_index == 10000 197 | @instance_index += 1 198 | end 199 | end 200 | end 201 | end 202 | 203 | struct Procodile::Process::Option 204 | include YAML::Serializable 205 | 206 | # How many instances of this process should be started 207 | property quantity : Int32? 208 | 209 | # Defines how this process should be restarted 210 | # 211 | # start-term = start new instances and send term to children 212 | # Signal::USR1 = just send a usr1 signal to the current instance 213 | # Signal::USR2 = just send a usr2 signal to the current instance 214 | # term-start = stop the old instances, when no longer running, start a new one 215 | property restart_mode : Signal | String | Nil 216 | 217 | # The maximum number of times this process can be respawned in the given period 218 | property max_respawns : Int32? 219 | 220 | # The respawn window. One hour by default. 221 | property respawn_window : Int32? 222 | property log_path : String? 223 | property log_file_name : String? 224 | 225 | # Return the signal to send to terminate the process 226 | property term_signal : Signal? 227 | 228 | # Return the first port that ports should be allocated from for this process 229 | property allocate_port_from : Int32? 230 | 231 | # Return the port for the proxy to listen on for this process type 232 | property proxy_port : Int32? 233 | 234 | # property proxy_address : String? 235 | property proxy_address : String? 236 | 237 | # Return the network protocol for this process 238 | property network_protocol : String? 239 | 240 | property env : Hash(String, String) = {} of String => String 241 | 242 | def initialize 243 | end 244 | 245 | def merge(other : self?) : self 246 | new_process_option = self 247 | 248 | {% for i in @type.instance_vars %} 249 | {% if i.name != "env" %} 250 | new_process_option.{{i.name}} = other.{{i.name}} if other.{{i.name}} 251 | {% end %} 252 | {% end %} 253 | 254 | new_process_option.env = new_process_option.env.merge(other.env) if other.env 255 | 256 | new_process_option 257 | end 258 | end 259 | -------------------------------------------------------------------------------- /src/procodile/signal_handler.cr: -------------------------------------------------------------------------------- 1 | # 信号处理程序 2 | # 3 | # 当一个信号被拦截后,首先,这个信号会被加入一个 QUEUE 4 | # 每一个信号又关联一个 handlers 列表 5 | # 当运行 #handle 方法时,会依次执行所有的处理器程序。 6 | 7 | module Procodile 8 | class SignalHandler 9 | # 保存用户发送的信号. 10 | QUEUE = [] of Signal 11 | 12 | # 允许的信号 13 | SIGNALS = { 14 | Signal::TERM, 15 | Signal::USR1, 16 | Signal::USR2, 17 | Signal::INT, 18 | Signal::HUP, 19 | } 20 | 21 | getter pipe : Hash(Symbol, IO::FileDescriptor) 22 | 23 | def initialize 24 | @handlers = {} of Signal => Array(Proc(Nil)) 25 | reader, writer = IO.pipe 26 | @pipe = {:reader => reader, :writer => writer} 27 | 28 | SIGNALS.each do |sig| 29 | sig.trap do 30 | QUEUE << sig 31 | notice 32 | end 33 | end 34 | end 35 | 36 | # 关联信号和处理函数 37 | # 38 | # 这个在 SignalHandler 对象创建之后,被手动调用 39 | def register(signal : Signal, &block : ->) : Nil 40 | @handlers[signal] ||= [] of Proc(Nil) 41 | 42 | @handlers[signal] << block 43 | end 44 | 45 | def notice : Nil 46 | @pipe[:writer].puts(".") 47 | end 48 | 49 | # 运行拦截的信号对应的处理函数 50 | def handle : Nil 51 | if (signal = QUEUE.shift?) 52 | Procodile.log nil, "system", "Supervisor received #{signal} signal" 53 | @handlers[signal].try &.each(&.call) 54 | end 55 | end 56 | end 57 | end 58 | -------------------------------------------------------------------------------- /src/procodile/start_supervisor.cr: -------------------------------------------------------------------------------- 1 | module Procodile 2 | class Supervisor 3 | def self.start( 4 | config : Config, 5 | options : CLI::Options = CLI::Options.new, 6 | &after_start : Proc(Supervisor, Nil) 7 | ) : Nil 8 | run_options = Supervisor::RunOptions.new( 9 | respawn: options.respawn?, 10 | stop_when_none: options.stop_when_none?, 11 | proxy: options.proxy?, 12 | force_single_log: options.foreground?, 13 | port_allocations: options.port_allocations, 14 | foreground: !!options.foreground? 15 | ) 16 | 17 | tidy_pids(config) 18 | 19 | if options.clean? 20 | FileUtils.rm_rf(Dir[File.join(config.pid_root, "*")]) 21 | puts "Emptied PID directory" 22 | end 23 | 24 | if !Dir[File.join(config.pid_root, "*")].empty? 25 | raise Error.new "The PID directory (#{config.pid_root}) is not empty. \ 26 | Cannot start unless things are clean." 27 | end 28 | 29 | set_process_title("[procodile] #{config.app_name} (#{config.root})") 30 | 31 | if options.foreground? 32 | File.write(config.supervisor_pid_path, ::Process.pid) 33 | 34 | Supervisor.new(config, run_options).start(after_start) 35 | else 36 | FileUtils.rm_rf(File.join(config.pid_root, "*.pid")) 37 | 38 | process = ::Process.fork do 39 | log_path = File.open(config.log_path, "a") 40 | STDOUT.reopen(log_path); STDOUT.sync = true 41 | STDERR.reopen(log_path); STDERR.sync = true 42 | 43 | Supervisor.new(config, run_options).start(after_start) 44 | end 45 | 46 | spawn { process.wait } 47 | 48 | pid = process.pid 49 | File.write(config.supervisor_pid_path, pid) 50 | 51 | puts "Started Procodile supervisor with PID #{pid}" 52 | end 53 | end 54 | 55 | # Clean up procodile.pid and procodile.sock with all unused pid files 56 | private def self.tidy_pids(config : Config) : Nil 57 | FileUtils.rm_rf(config.supervisor_pid_path) 58 | FileUtils.rm_rf(config.sock_path) 59 | 60 | pid_files = Dir[File.join(config.pid_root, "*.pid")] 61 | 62 | pid_files.each do |pid_path| 63 | file_name = pid_path.split("/").last 64 | pid = File.read(pid_path).to_i 65 | 66 | if ::Process.exists?(pid) 67 | puts "Could not remove #{file_name} because process (#{pid}) was active" 68 | else 69 | FileUtils.rm_rf(pid_path) 70 | puts "Removed #{file_name} because process was not active" 71 | end 72 | end 73 | end 74 | 75 | private def self.set_process_title(title : String) : Nil 76 | # Set $PROGRAM_NAME in linux 77 | File.write("/proc/self/comm", title) 78 | end 79 | end 80 | end 81 | -------------------------------------------------------------------------------- /src/procodile/supervisor.cr: -------------------------------------------------------------------------------- 1 | require "./logger" 2 | require "./control_server" 3 | require "./signal_handler" 4 | 5 | module Procodile 6 | class Supervisor 7 | @started_at : Time? 8 | 9 | getter tag : String? 10 | getter started_at : Time? 11 | getter config : Config 12 | getter run_options : Supervisor::RunOptions 13 | getter processes : Hash(Procodile::Process, Array(Instance)) = {} of Procodile::Process => Array(Instance) 14 | getter readers : Hash(IO::FileDescriptor, Instance) = {} of IO::FileDescriptor => Instance 15 | 16 | def initialize( 17 | @config : Config, 18 | @run_options : Supervisor::RunOptions = Supervisor::RunOptions.new, 19 | ) 20 | @signal_handler = SignalHandler.new 21 | @signal_handler_chan = Channel(Nil).new 22 | @log_listener_chan = Channel(Nil).new 23 | 24 | @signal_handler.register(Signal::TERM) { stop_supervisor } 25 | @signal_handler.register(Signal::INT) { stop(Supervisor::Options.new(stop_supervisor: true)) } 26 | @signal_handler.register(Signal::USR1) { restart } 27 | @signal_handler.register(Signal::USR2) { } 28 | @signal_handler.register(Signal::HUP) { reload_config } 29 | end 30 | 31 | def allow_respawning? : Bool 32 | @run_options.respawn? != false 33 | end 34 | 35 | def start(after_start : Proc(Supervisor, Nil)) : Nil 36 | Procodile.log nil, "system", "Procodile supervisor started with PID #{::Process.pid}" 37 | Procodile.log nil, "system", "Application root is #{@config.root}" 38 | 39 | if @run_options.respawn? == false 40 | Procodile.log nil, "system", "Automatic respawning is disabled" 41 | end 42 | 43 | ControlServer.start(self) 44 | 45 | after_start.call(self) # invoke supervisor.start_processes 46 | 47 | watch_for_output 48 | 49 | @started_at = Time.local 50 | rescue e 51 | Procodile.log nil, "system", "Error: #{e.class} (#{e.message})" 52 | 53 | e.backtrace.each { |bt| Procodile.log nil, "system", "=> #{bt})" } 54 | 55 | stop(Supervisor::Options.new(stop_supervisor: true)) 56 | ensure 57 | loop { supervise; sleep 3.seconds } 58 | end 59 | 60 | def start_processes( 61 | process_names : Array(String)?, 62 | options : Supervisor::Options = Supervisor::Options.new, 63 | ) : Array(Instance) 64 | @tag = options.tag 65 | instances_started = [] of Instance 66 | 67 | reload_config 68 | 69 | @config.processes.each do |name, process| 70 | next if process_names && !process_names.includes?(name.to_s) # Not a process we want 71 | next if @processes[process]? && !@processes[process].empty? # Process type already running 72 | 73 | instances = process.generate_instances(self) 74 | instances.each &.start 75 | instances_started.concat instances 76 | end 77 | 78 | instances_started 79 | end 80 | 81 | def stop(options : Supervisor::Options = Supervisor::Options.new) : Array(Instance) 82 | @run_options.stop_when_none = true if options.stop_supervisor 83 | 84 | reload_config 85 | 86 | processes = options.processes 87 | instances_stopped = [] of Instance 88 | 89 | if processes.nil? 90 | Procodile.log nil, "system", "Stopping all #{@config.app_name} processes" 91 | 92 | @processes.each do |_, instances| 93 | instances.each do |instance| 94 | instance.stop 95 | instances_stopped << instance 96 | end 97 | end 98 | else 99 | instances = process_names_to_instances(processes) 100 | 101 | Procodile.log nil, "system", "Stopping #{instances.size} process(es)" 102 | 103 | instances.each do |instance| 104 | instance.stop 105 | instances_stopped << instance 106 | end 107 | end 108 | 109 | instances_stopped 110 | end 111 | 112 | def run_use_foreground? : Bool 113 | @run_options.foreground? 114 | end 115 | 116 | def restart( 117 | options : Supervisor::Options = Supervisor::Options.new, 118 | ) : Array(Array(Instance | Nil)) 119 | wg = WaitGroup.new 120 | @tag = options.tag 121 | instances_restarted = [] of Array(Instance?) 122 | processes = options.processes 123 | 124 | reload_config 125 | 126 | if processes.nil? 127 | instances = @processes.values.flatten 128 | 129 | Procodile.log nil, "system", "Restarting all #{@config.app_name} processes" 130 | else 131 | instances = process_names_to_instances(processes) 132 | 133 | Procodile.log nil, "system", "Restarting #{instances.size} process(es)" 134 | end 135 | 136 | # Stop any processes that are no longer wanted at this point 137 | stopped = check_instance_quantities(:stopped, processes)[:stopped].map { |i| [i, nil] } 138 | instances_restarted.concat stopped 139 | 140 | instances.each do |instance| 141 | next if instance.stopping? 142 | 143 | new_instance = instance.restart(wg) 144 | instances_restarted << [instance, new_instance] 145 | end 146 | 147 | # Start any processes that are needed at this point 148 | checked = check_instance_quantities(:started, processes)[:started].map { |i| [nil, i] } 149 | instances_restarted.concat checked 150 | 151 | # 确保所有的 @reader 设定完毕,再启动 log listener 152 | # 这个代码仍旧有机会造成 UNIXSever 立即退出,但是没有任何 backtrace, 原因未知 153 | wg.wait 154 | 155 | log_listener_reader 156 | 157 | instances_restarted 158 | end 159 | 160 | def stop_supervisor : Nil 161 | Procodile.log nil, "system", "Stopping Procodile supervisor" 162 | 163 | FileUtils.rm_rf(File.join(@config.pid_root, "procodile.pid")) 164 | 165 | exit 0 166 | end 167 | 168 | def reload_config : Nil 169 | Procodile.log nil, "system", "Reloading configuration" 170 | 171 | @config.reload 172 | end 173 | 174 | def check_concurrency( 175 | options : Supervisor::Options = Supervisor::Options.new, 176 | ) : Hash(Symbol, Array(Instance)) 177 | Procodile.log nil, "system", "Checking process concurrency" 178 | 179 | reload_config unless options.reload == false 180 | 181 | result = check_instance_quantities 182 | 183 | if result[:started].empty? && result[:stopped].empty? 184 | Procodile.log nil, "system", "Process concurrency looks good" 185 | else 186 | if result[:started].present? 187 | Procodile.log nil, "system", "Concurrency check \ 188 | started #{result[:started].map(&.description).join(", ")}" 189 | end 190 | 191 | if result[:stopped].present? 192 | Procodile.log nil, "system", "Concurrency check \ 193 | stopped #{result[:stopped].map(&.description).join(", ")}" 194 | end 195 | end 196 | 197 | result 198 | end 199 | 200 | def to_hash : NamedTuple(started_at: Int64?, pid: Int64) 201 | started_at = @started_at 202 | 203 | { 204 | started_at: started_at ? started_at.to_unix : nil, 205 | pid: ::Process.pid, 206 | } 207 | end 208 | 209 | def messages : Array(Message) 210 | messages = [] of Message 211 | 212 | processes.each do |process, process_instances| 213 | unless process.correct_quantity?(process_instances.size) 214 | messages << Message.new( 215 | type: :incorrect_quantity, 216 | process: process.name, 217 | current: process_instances.size, 218 | desired: process.quantity, 219 | ) 220 | end 221 | 222 | process_instances.each do |instance| 223 | if instance.should_be_running? && !instance.status.running? 224 | messages << Message.new( 225 | type: :not_running, 226 | instance: instance.description, 227 | status: instance.status, 228 | ) 229 | end 230 | end 231 | end 232 | 233 | messages 234 | end 235 | 236 | def add_instance(instance : Instance, io : IO::FileDescriptor? = nil) : Nil 237 | add_reader(instance, io) if io 238 | 239 | # When the first time start, it is possible @processes[instance.process] is nil 240 | # before the process is started. 241 | @processes[instance.process] ||= [] of Instance 242 | 243 | unless @processes[instance.process].includes?(instance) 244 | @processes[instance.process] << instance 245 | end 246 | end 247 | 248 | def remove_instance(instance : Instance) : Nil 249 | if @processes[instance.process] 250 | @processes[instance.process].delete(instance) 251 | 252 | # Only useful when run in foreground 253 | key = @readers.key_for?(instance) 254 | @readers.delete(key) if key 255 | end 256 | end 257 | 258 | private def supervise : Nil 259 | # Tell instances that have been stopped that they have been stopped 260 | remove_stopped_instances 261 | 262 | # Remove removed processes 263 | remove_removed_processes 264 | 265 | # Check all instances that we manage and let them do their things. 266 | @processes.each do |_, instances| 267 | instances.each(&.check) 268 | end 269 | 270 | # If the processes go away, we can stop the supervisor now 271 | if @run_options.stop_when_none? && all_instances_stopped? 272 | Procodile.log nil, "system", "All processes have stopped" 273 | 274 | stop_supervisor 275 | end 276 | end 277 | 278 | private def watch_for_output : Nil 279 | spawn do 280 | loop do 281 | @signal_handler.pipe[:reader].gets 282 | @signal_handler.handle 283 | 284 | @signal_handler_chan.send nil 285 | end 286 | end 287 | 288 | log_listener_reader 289 | 290 | spawn do 291 | loop do 292 | select 293 | when @signal_handler_chan.receive 294 | when @log_listener_chan.receive 295 | when timeout 30.seconds 296 | end 297 | end 298 | end 299 | end 300 | 301 | private def log_listener_reader : Nil 302 | buffer = {} of IO::FileDescriptor => String 303 | # After run restart command, @readers need to be update. 304 | # Ruby version @readers is wrapped by a loop, so can workaround this. 305 | # Crystal version need rerun this method again after restart. 306 | @readers.keys.each do |reader| 307 | spawn do 308 | loop do 309 | Fiber.yield 310 | 311 | if (str = reader.gets(chomp: true)).nil? 312 | sleep 0.1.seconds 313 | next 314 | end 315 | 316 | buffer[reader] ||= "" 317 | buffer[reader] += "#{str}\n" 318 | 319 | while buffer[reader].index("\n") 320 | line, buffer[reader] = buffer[reader].split("\n", 2) 321 | 322 | if (instance = @readers[reader]) 323 | Procodile.log( 324 | instance.process.log_color, 325 | instance.description, 326 | "#{"=>".colorize(instance.process.log_color)} #{line}" 327 | ) 328 | else 329 | Procodile.log nil, "unknown", buffer[reader] 330 | end 331 | end 332 | 333 | @log_listener_chan.send nil 334 | end 335 | end 336 | end 337 | end 338 | 339 | private def check_instance_quantities( 340 | type : Supervisor::CheckInstanceQuantitiesType = :both, 341 | processes : Array(String)? = nil, 342 | ) : Hash(Symbol, Array(Instance)) 343 | status = {:started => [] of Instance, :stopped => [] of Instance} 344 | 345 | @processes.each do |process, instances| 346 | next if processes && !processes.includes?(process.name) 347 | 348 | if (type.both? || type.stopped?) && instances.size > process.quantity 349 | quantity_to_stop = instances.size - process.quantity 350 | stopped_instances = instances.first(quantity_to_stop) 351 | 352 | Procodile.log nil, "system", "Stopping #{quantity_to_stop} #{process.name} process(es)" 353 | 354 | stopped_instances.each(&.stop) 355 | status[:stopped] = stopped_instances 356 | end 357 | 358 | if (type.both? || type.started?) && instances.size < process.quantity 359 | quantity_needed = process.quantity - instances.size 360 | started_instances = process.generate_instances(self, quantity_needed) 361 | 362 | Procodile.log nil, "system", "Starting #{quantity_needed} more #{process.name} process(es)" 363 | 364 | started_instances.each(&.start) 365 | 366 | status[:started].concat(started_instances) 367 | end 368 | end 369 | 370 | status 371 | end 372 | 373 | private def remove_stopped_instances : Nil 374 | @processes.each do |_, instances| 375 | instances.reject! do |instance| 376 | if instance.stopping? && !instance.running? 377 | instance.on_stop 378 | 379 | true 380 | else 381 | false 382 | end 383 | end 384 | end 385 | end 386 | 387 | private def remove_removed_processes : Nil 388 | @processes.reject! do |process, instances| 389 | if process.removed && instances.empty? 390 | true 391 | else 392 | false 393 | end 394 | end 395 | end 396 | 397 | private def process_names_to_instances(names : Array(String)) : Array(Instance) 398 | names.each_with_object([] of Instance) do |name, array| 399 | if name =~ /\A(.*)\.(\d+)\z/ # app1.1 400 | process_name, id = $1, $2 401 | 402 | @processes.each do |process, instances| 403 | next unless process.name == process_name 404 | 405 | instances.each do |instance| 406 | next unless instance.id == id.to_i 407 | 408 | array << instance 409 | end 410 | end 411 | else 412 | @processes.each do |process, instances| 413 | next unless process.name == name 414 | 415 | instances.each { |instance| array << instance } 416 | end 417 | end 418 | end 419 | end 420 | 421 | private def all_instances_stopped? : Bool 422 | @processes.all? do |_, instances| 423 | instances.reject(&.failed?).empty? 424 | end 425 | end 426 | 427 | private def add_reader(instance : Instance, io : IO::FileDescriptor) : Nil 428 | @readers[io] = instance 429 | 430 | @signal_handler.notice 431 | end 432 | 433 | # Supervisor message 434 | struct Message 435 | # Message type 436 | enum Type 437 | NotRunning 438 | IncorrectQuantity 439 | end 440 | 441 | include JSON::Serializable 442 | 443 | getter type : Type 444 | getter process : String? 445 | getter current : Int32? 446 | getter desired : Int32? 447 | getter instance : String? 448 | getter status : Instance::Status? 449 | 450 | def initialize( 451 | @type : Type, 452 | @process : String? = nil, 453 | @current : Int32? = nil, 454 | @desired : Int32? = nil, 455 | @instance : String? = nil, 456 | @status : Instance::Status? = nil, 457 | ) 458 | end 459 | 460 | def to_s(io : IO) : Nil 461 | case type 462 | in .not_running? 463 | io.print "#{instance} is not running (#{status})" 464 | in .incorrect_quantity? 465 | io.print "#{process} has #{current} instances (should have #{desired})" 466 | end 467 | end 468 | end 469 | end 470 | 471 | enum Supervisor::CheckInstanceQuantitiesType 472 | Both 473 | Started 474 | Stopped 475 | end 476 | 477 | struct Supervisor::RunOptions 478 | property port_allocations : Hash(String, Int32)? 479 | 480 | property? proxy : Bool? 481 | property? foreground : Bool 482 | property? force_single_log : Bool? 483 | property? respawn : Bool? 484 | property? stop_when_none : Bool? 485 | 486 | def initialize( 487 | @respawn : Bool?, 488 | @stop_when_none : Bool?, 489 | @force_single_log : Bool?, 490 | @port_allocations : Hash(String, Int32)?, 491 | @proxy : Bool?, 492 | @foreground : Bool = false, 493 | ) 494 | end 495 | end 496 | 497 | # 这种写法允许以任意方式初始化 Supervisor::Options 498 | struct Supervisor::Options 499 | getter processes : Array(String)? 500 | getter stop_supervisor : Bool? 501 | getter tag : String? 502 | getter reload : Bool? 503 | 504 | def initialize( 505 | @processes : Array(String)? = nil, 506 | @stop_supervisor : Bool? = nil, 507 | @tag : String? = nil, 508 | @reload : Bool? = nil, 509 | ) 510 | end 511 | end 512 | end 513 | -------------------------------------------------------------------------------- /src/requires.cr: -------------------------------------------------------------------------------- 1 | require "option_parser" 2 | require "colorize" 3 | require "yaml" 4 | require "json" 5 | require "socket" 6 | require "file_utils" 7 | require "wait_group" 8 | require "tail" 9 | -------------------------------------------------------------------------------- /test-app/Procfile: -------------------------------------------------------------------------------- 1 | web: ruby web.rb 2 | #worker: ruby worker.rb 3 | #cron: ruby cron.rb 4 | -------------------------------------------------------------------------------- /test-app/Procfile.local: -------------------------------------------------------------------------------- 1 | env: 2 | SMTP_USERNAME: bananas 3 | processes: 4 | web: 5 | proxy_port: 8080 6 | quantity: 1 7 | -------------------------------------------------------------------------------- /test-app/Procfile.options: -------------------------------------------------------------------------------- 1 | app_name: BananaApp 2 | env: 3 | SMTP_HOSTNAME: smtp.deliverhq.com 4 | SMTP_USERNAME: test123123 5 | processes: 6 | web: 7 | quantity: 1 8 | max_respawns: 2 9 | worker: 10 | quantity: 1 11 | restart_mode: start-term 12 | env: 13 | PORT: 5123 14 | cron: 15 | quantity: 0 16 | restart_mode: usr1 17 | -------------------------------------------------------------------------------- /test-app/cron.rb: -------------------------------------------------------------------------------- 1 | trap("USR1", proc { 2 | puts "Restarting!" 3 | $stdout.flush 4 | pid = fork do 5 | exec("ruby cron.rb") 6 | end 7 | File.write(ENV["PID_FILE"], "#{pid}\n") 8 | puts "Created new process with PID #{pid}" 9 | $stdout.flush 10 | }) 11 | 12 | trap("TERM", proc { 13 | $stdout.flush 14 | Process.exit(1) 15 | }) 16 | 17 | if ENV["DONE"] 18 | puts "Killing original parent at #{Process.ppid}" 19 | Process.kill("TERM", Process.ppid) 20 | end 21 | 22 | ENV["DONE"] = "1" 23 | 24 | puts "Cron running with PID #{Process.pid}" 25 | $stdout.flush 26 | loop { sleep 60 } 27 | -------------------------------------------------------------------------------- /test-app/global_config.yml: -------------------------------------------------------------------------------- 1 | user: blah 2 | user_reexec: true 3 | -------------------------------------------------------------------------------- /test-app/web.rb: -------------------------------------------------------------------------------- 1 | trap("TERM", proc { puts "Exiting..."; $stdout.flush; Process.exit(0) }) 2 | 3 | puts "Web server running on port 5000. \n Isn't this nice?\n This is on multiple lines." 4 | $stdout.flush 5 | puts "Root: #{ENV['APP_ROOT']}" 6 | puts "PID file: #{ENV['PID_FILE']}" 7 | puts "SMTP server: #{ENV['SMTP_HOSTNAME']}" 8 | puts "SMTP user: #{ENV['SMTP_USERNAME']}" 9 | puts "Port: #{ENV['PORT']}" 10 | $stdout.flush 11 | require "socket" 12 | server = TCPServer.new("127.0.0.1", ENV["PORT"] || 5000) 13 | loop do 14 | io = IO.select([server], nil, nil, 0.5) 15 | if io&.first 16 | io.first.each do |fd| 17 | if client = fd.accept 18 | puts "Connection from #{client.addr[3]}" 19 | $stdout.flush 20 | client.puts "Hello" 21 | if data = client.gets 22 | client.puts "you sent: #{data}" 23 | end 24 | puts "Closed connection" 25 | $stdout.flush 26 | client.close 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test-app/worker.rb: -------------------------------------------------------------------------------- 1 | trap("TERM", proc { $exit = 1; puts("Exiting..."); $stdout.flush }) 2 | 3 | puts "Working running on #{ENV['PORT']}" 4 | $stdout.flush 5 | count = 0 6 | loop do 7 | count +=1 8 | sleep 2 9 | Process.exit(0) if $exit 10 | end 11 | --------------------------------------------------------------------------------