├── .circleci └── config.yml ├── .gitignore ├── README.md ├── shard.yml ├── spec ├── env_spec.cr └── fixtures │ └── .env └── src ├── crank.cr └── crank ├── cli.cr ├── engine.cr ├── env.cr ├── process.cr ├── procfile.cr ├── timeout.cr └── version.cr /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | jobs: 3 | test: 4 | docker: 5 | - image: crystallang/crystal:0.34.0 6 | steps: 7 | - checkout 8 | - run: 9 | name: test 10 | command: crystal spec 11 | 12 | test_nightly: 13 | docker: 14 | - image: crystallang/crystal:latest 15 | steps: 16 | - run: crystal --version 17 | - checkout 18 | - run: 19 | name: test 20 | command: crystal spec 21 | 22 | workflows: 23 | version: 2 24 | ci: 25 | jobs: 26 | - test 27 | weekly: 28 | triggers: 29 | - schedule: 30 | cron: "0 0 * * 0" 31 | filters: 32 | branches: 33 | only: 34 | - master 35 | jobs: 36 | - test_nightly -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.crystal 2 | /man/*.html 3 | /man/*.markdown 4 | Procfile 5 | .env 6 | bc_flags 7 | crank.o 8 | ./crank 9 | bin/ 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Crank 2 | 3 | Foreman in Crystal 4 | 5 | This is very much a work in progress. 6 | 7 | ## Installation 8 | 9 | Clone this repo 10 | 11 | `git clone https://github.com/arktisklada/crank.git` 12 | 13 | Symlink `/usr/bin/crank` (or a folder in your path) to `bin/crank` in this cloned repo. 14 | 15 | ## Usage 16 | 17 | With the following `Procfile`: 18 | 19 | ``` 20 | web: bin/server 7000 21 | worker: bin/worker queue=FOO 22 | ``` 23 | 24 | Run `crank` 25 | 26 | ``` 27 | 17:12:48 web | listening on port 7000 28 | 17:12:48 worker | listening to queue FOO 29 | ``` 30 | 31 | ## Credits 32 | 33 | Inspired by the original [Foreman](https://github.com/ddollar/foreman) by David Dollar (@ddollar). 34 | 35 | ## License 36 | 37 | (Apache 2.0)[http://www.apache.org/licenses/LICENSE-2.0] © 2017 Clayton Liggitt 38 | -------------------------------------------------------------------------------- /shard.yml: -------------------------------------------------------------------------------- 1 | name: crank 2 | 3 | authors: 4 | - Clayton Liggitt (@artisklada) - Creator 5 | - Andy Nicholson (@anicholson) - Maintenance 6 | 7 | version: 0.2.0 8 | 9 | description : | 10 | A crystal port of the popular Foreman project by @ddollar 11 | 12 | targets: 13 | crank: 14 | main: src/crank.cr 15 | 16 | executables: 17 | - crank 18 | 19 | license: Apache License v2.0 20 | -------------------------------------------------------------------------------- /spec/env_spec.cr: -------------------------------------------------------------------------------- 1 | require "spec" 2 | require "../src/crank/env.cr" 3 | 4 | describe Crank::Env do 5 | describe ".new" do 6 | it "parses the file for environment variables" do 7 | file = "#{Dir.current}/spec/fixtures/.env" 8 | puts file 9 | 10 | env = Crank::Env.new(file) 11 | 12 | entries = Hash(String, String).new 13 | env.entries do |key, value| 14 | entries[key] = value 15 | end 16 | 17 | entries["TEST"].should eq("test_value") 18 | entries["SINGLE_QUOTES"].should eq("are stripped") 19 | entries["DOUBLE_QUOTES"].should eq("are also stripped") 20 | end 21 | end 22 | end -------------------------------------------------------------------------------- /spec/fixtures/.env: -------------------------------------------------------------------------------- 1 | TEST=test_value 2 | SINGLE_QUOTES='are stripped' 3 | DOUBLE_QUOTES="are also stripped" -------------------------------------------------------------------------------- /src/crank.cr: -------------------------------------------------------------------------------- 1 | require "../src/crank/cli.cr" 2 | 3 | Crank::CLI.start 4 | -------------------------------------------------------------------------------- /src/crank/cli.cr: -------------------------------------------------------------------------------- 1 | require "colorize" 2 | require "../crank.cr" 3 | require "./engine.cr" 4 | 5 | module Crank 6 | class CLI 7 | ERROR_COLOR = :red 8 | PROCFILE = "Procfile" 9 | DOT_ENV = ".env" 10 | 11 | def self.start 12 | check_procfile! 13 | 14 | engine = Crank::Engine.new(PROCFILE, DOT_ENV) 15 | engine.start 16 | end 17 | 18 | def self.error(message) 19 | puts "ERROR: #{message}".colorize(ERROR_COLOR) 20 | exit 1 21 | end 22 | 23 | def self.check_procfile! 24 | unless File.exists?(PROCFILE) 25 | error("#{PROCFILE} does not exist.") 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /src/crank/engine.cr: -------------------------------------------------------------------------------- 1 | require "../crank.cr" 2 | require "./timeout.cr" 3 | require "./process.cr" 4 | require "./procfile.cr" 5 | require "./env.cr" 6 | 7 | module Crank 8 | class Engine 9 | HANDLED_SIGNALS = [Signal::TERM, Signal::INT, Signal::HUP, Signal::ABRT] 10 | COLORS = %i(green 11 | yellow 12 | blue 13 | magenta 14 | cyan 15 | light_gray 16 | dark_gray 17 | light_red 18 | light_green 19 | light_yellow 20 | light_blue 21 | light_magenta 22 | light_cyan 23 | ) 24 | ERROR_COLOR = :red 25 | SYSTEM_COLOR = :white 26 | 27 | property :writer 28 | 29 | @output : IO::FileDescriptor 30 | 31 | def initialize(procfile : String, env_file : String) 32 | @procfile = procfile 33 | @env_file = env_file 34 | # _, @output = IO.pipe(write_blocking: true) 35 | # @output.colorize 36 | @output = STDOUT 37 | @channel = Channel(Int32).new 38 | @processes = [] of Crank::Process 39 | @running = {} of Int32 => Crank::Process 40 | @terminating = false 41 | @env = {} of String => String 42 | end 43 | 44 | # Populate runtime environment variables from a .env file 45 | def load_env(filename : String) 46 | unless File.exists?(env_file) 47 | return 48 | end 49 | 50 | root = File.dirname(filename) 51 | Crank::Env.new(filename).entries do |key, value| 52 | env[key.to_s] = value.to_s 53 | end 54 | end 55 | 56 | # Populate the list of processes from the given Procfile 57 | def load_procfile(filename : String) 58 | root = File.dirname(filename) 59 | Crank::Procfile.new(filename).entries do |name, command| 60 | processes << Crank::Process.new(name, command, env) 61 | end 62 | end 63 | 64 | # Starts the Engine processes, loads necessary files, and registers handlers 65 | def start 66 | load_env(env_file) 67 | load_procfile(procfile) 68 | 69 | # delay(2) { terminate_gracefully } 70 | register_signal_handlers 71 | spawn_processes 72 | watch_for_ended_processes 73 | end 74 | 75 | private getter :procfile, :env_file, :env, :processes, :output, :channel, :running, :terminating 76 | private setter :terminating 77 | 78 | private def write(string : String, color : Symbol = SYSTEM_COLOR, line_break : Bool = true) 79 | line_break_string = "" 80 | if line_break || string[-2] != "\n" 81 | line_break_string = "\n" 82 | end 83 | output << "#{string}#{line_break_string}".colorize(color) 84 | end 85 | 86 | private def spawn_processes 87 | processes.each_with_index do |process, index| 88 | name = process.name 89 | color = COLORS[index] 90 | begin 91 | process.run do |output, error| 92 | spawn do 93 | spawn do 94 | while process_output = output.gets 95 | write build_output(name, process_output), color, true 96 | end 97 | end 98 | 99 | spawn do 100 | while process_error = error.gets 101 | write build_output(name, process_error), ERROR_COLOR, true 102 | end 103 | end 104 | 105 | status = process.wait 106 | channel.send process.pid 107 | end 108 | end 109 | 110 | running[process.pid] = process 111 | write build_output(name, "started with pid #{process.pid}"), color 112 | rescue # Errno::ENOENT 113 | write build_output(name, "unknown command: #{process.command}"), ERROR_COLOR 114 | end 115 | end 116 | end 117 | 118 | private def build_output(name, output) 119 | longest_name = processes.map { |p| p.name.size }.max 120 | 121 | filler_spaces = "" 122 | (longest_name - name.size).times do 123 | filler_spaces += " " 124 | end 125 | 126 | "#{Time.local.to_s("%H:%M:%S")} #{name} #{filler_spaces}| #{output.to_s}" 127 | end 128 | 129 | private def watch_for_ended_processes 130 | if ended_pid = channel.receive 131 | if running.has_key? ended_pid 132 | ended_process = running[ended_pid] 133 | write build_output(ended_process.name, "exited!") 134 | 135 | if id = running.delete ended_pid 136 | terminate_gracefully 137 | end 138 | end 139 | end 140 | end 141 | 142 | private def kill_children(signal = Signal::TERM) 143 | running.each_key do |pid| 144 | spawn do 145 | begin 146 | ::Process.kill signal, pid 147 | rescue 148 | if signal == Signal::TERM 149 | kill_children Signal::KILL 150 | end 151 | end 152 | 153 | running.delete pid 154 | channel.send pid 155 | end 156 | end 157 | end 158 | 159 | private def terminate_gracefully 160 | return if terminating 161 | restore_default_signal_handlers 162 | terminating = true 163 | 164 | write "sending SIGTERM to all processes" 165 | kill_children Signal::TERM 166 | 167 | timeout = 60 168 | 169 | timeout_error_handler = ->do 170 | write "sending SIGKILL to all processes" 171 | kill_children Signal::KILL 172 | end 173 | 174 | Timeout.timeout(timeout, timeout_error_handler) do 175 | while running.size > 0 176 | print "." 177 | sleep 0.1 178 | end 179 | print "\n" 180 | end 181 | end 182 | 183 | private def register_signal_handlers 184 | HANDLED_SIGNALS.each do |signal| 185 | signal.trap do 186 | handle_signal(signal) 187 | end 188 | end 189 | end 190 | 191 | private def restore_default_signal_handlers 192 | HANDLED_SIGNALS.each do |signal| 193 | signal.reset 194 | end 195 | end 196 | 197 | private def handle_signal(signal) 198 | case signal 199 | when Signal::TERM 200 | write "SIGTERM received" 201 | when Signal::INT 202 | write "SIGINT received" 203 | when Signal::HUP 204 | write "SIGHUP received" 205 | else 206 | write "unhandled signal #{signal}" 207 | end 208 | 209 | terminate_gracefully 210 | end 211 | end 212 | end 213 | -------------------------------------------------------------------------------- /src/crank/env.cr: -------------------------------------------------------------------------------- 1 | module Crank 2 | class Env 3 | @filename : String 4 | @entries : Array(Array(String)) 5 | 6 | def initialize(filename : String) 7 | @filename = filename 8 | @entries = parse 9 | end 10 | 11 | def entries(&block) 12 | entries.each do |entry| 13 | key = entry[0] 14 | value = entry[1] 15 | yield key, value 16 | end 17 | end 18 | 19 | private getter :filename, :entries 20 | 21 | private def parse 22 | puts "Loading environment variables from #{filename}" 23 | 24 | file_lines = File.read(filename).gsub("\r\n", "\n").split("\n") 25 | file_lines.map do |line| 26 | if line =~ /\A([A-Za-z_0-9]+)=(.*)\z/ 27 | key = $1 28 | value = $2.rstrip 29 | if value =~ /\A'(.*)'\z/ 30 | # Remove single quotes 31 | value = $1 32 | elsif value =~ /\A"(.*)"\z/ 33 | # Remove double quotes and unescape string preserving newline characters 34 | value = $1.gsub('\n', "\n").gsub(/\\(.)/, "\\1", true) 35 | end 36 | 37 | [key, value] 38 | end 39 | end.compact 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /src/crank/process.cr: -------------------------------------------------------------------------------- 1 | module Crank 2 | class Process 3 | @command : String 4 | @args : Array(String) 5 | 6 | getter :command, :name, :args 7 | 8 | def initialize(name : String, full_command : String, env = {} of String => String) 9 | @name = name 10 | 11 | command_parts = full_command.split(/\s+/) 12 | @command = command_parts[0] 13 | @args = command_parts[1..] 14 | 15 | @process = ::Process.new( 16 | @command, 17 | @args, 18 | env: env, 19 | shell: true, 20 | output: ::Process::Redirect::Pipe, 21 | error: ::Process::Redirect::Pipe 22 | ) 23 | end 24 | 25 | def run 26 | yield @process.output, @process.error 27 | end 28 | 29 | def wait 30 | @process.wait 31 | end 32 | 33 | def pid 34 | @process.pid 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /src/crank/procfile.cr: -------------------------------------------------------------------------------- 1 | module Crank 2 | class Procfile 3 | @filename : String 4 | 5 | def initialize(filename : String) 6 | @filename = filename 7 | @entries = [] of Array(String) 8 | parse! 9 | end 10 | 11 | def entries(&block) 12 | entries.each do |entry| 13 | name = entry[0] 14 | command = entry[1] 15 | yield name, command 16 | end 17 | end 18 | 19 | private getter :filename, :entries 20 | 21 | private def parse! 22 | file_lines = File.read(filename).gsub("\r\n", "\n").split("\n") 23 | @entries = file_lines.map do |line| 24 | if line =~ /^([A-Za-z0-9_-]+):\s*(.+)$/ 25 | [$1, $2] 26 | end 27 | end.compact 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /src/crank/timeout.cr: -------------------------------------------------------------------------------- 1 | module Timeout 2 | def self.timeout(seconds, timeout_handler, &block) 3 | if seconds == nil || seconds == 0 4 | return block.call 5 | end 6 | 7 | timeout_thread = delay(seconds) { timeout_handler.call } 8 | 9 | block.call 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /src/crank/version.cr: -------------------------------------------------------------------------------- 1 | module Crank 2 | VERSION = "0.2.0" 3 | end 4 | --------------------------------------------------------------------------------