procodile 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 12da3acd72adc0bf302ddad40c02133f308cf168
4
+ data.tar.gz: e13faabcbbadce0fd6ac91885c3b5ba937f398db
5
+ SHA512:
6
+ metadata.gz: 8b61d0f1fb351bf1f05eac2ea0d22accd8ff2ebbe51fd551397ac5c8ee17693ffa36f6a7f3d2e9729774cf1dabac99358930de70c45c0aa2cd95b1d9359ffb06
7
+ data.tar.gz: b2f481642d8e7015808946d07cf6b7d0c027e1d2b8956518b27a61bd670e851e690fbf98c79cd3ec02b042d6c176c275794abe6ee2139effc234fc60152b65d9
data/bin/procodile ADDED
@@ -0,0 +1,30 @@
1
+ #!/usr/bin/env ruby
2
+ require 'optparse'
3
+ options = {}
4
+ OptionParser.new do |opts|
5
+ opts.banner = "Usage: procodile [options]"
6
+ opts.on("-r", "--root PATH", "The path to the root of your application") do |root|
7
+ options[:root] = root
8
+ end
9
+ end.parse!
10
+
11
+ $:.unshift(File.expand_path('../../lib', __FILE__))
12
+
13
+ require 'fileutils'
14
+ require 'procodile/error'
15
+ require 'procodile/config'
16
+ require 'procodile/cli'
17
+
18
+ Thread.abort_on_exception = true
19
+ begin
20
+
21
+ config = Procodile::Config.new(options[:root] ? File.expand_path(options[:root]) : FileUtils.pwd)
22
+ command = ARGV[0]
23
+
24
+ cli = Procodile::CLI.new(config)
25
+ cli.run(command)
26
+
27
+ rescue Procodile::Error => e
28
+ $stderr.puts "\e[31merror: #{e.message}\e[0m"
29
+ exit 1
30
+ end
data/lib/procodile.rb ADDED
@@ -0,0 +1,2 @@
1
+ module Procodile
2
+ end
@@ -0,0 +1,131 @@
1
+ require 'fileutils'
2
+ require 'procodile/error'
3
+ require 'procodile/supervisor'
4
+ require 'procodile/signal_handler'
5
+
6
+ module Procodile
7
+ class CLI
8
+
9
+ def initialize(config)
10
+ @config = config
11
+ end
12
+
13
+ def run(command)
14
+ if self.class.instance_methods(false).include?(command.to_sym) && command != 'run'
15
+ public_send(command)
16
+ else
17
+ raise Error, "Invalid command '#{command}'"
18
+ end
19
+ end
20
+
21
+ def start
22
+ if running?
23
+ raise Error, "#{@config.app_name} already running (PID: #{current_pid})"
24
+ end
25
+ FileUtils.rm_f(File.join(@config.pid_root, "*.pid"))
26
+ pid = fork do
27
+ STDOUT.reopen(log_path, 'a')
28
+ STDOUT.sync = true
29
+ STDERR.reopen(log_path, 'a')
30
+ STDERR.sync = true
31
+ supervisor = Supervisor.new(@config)
32
+ signal_handler = SignalHandler.new('TERM', 'USR1', 'USR2', 'INT', 'HUP')
33
+ signal_handler.register('TERM') { supervisor.stop }
34
+ signal_handler.register('USR1') { supervisor.restart }
35
+ signal_handler.register('USR2') { supervisor.status }
36
+ signal_handler.register('INT') { supervisor.stop_supervisor }
37
+ supervisor.start
38
+ end
39
+ ::Process.detach(pid)
40
+ File.open(pid_path, 'w') { |f| f.write(pid) }
41
+ puts "Started #{@config.app_name} supervisor with PID #{pid}"
42
+ end
43
+
44
+ def stop
45
+ if running?
46
+ ::Process.kill('TERM', current_pid)
47
+ puts "Stopping #{@config.app_name} processes & supervisor..."
48
+ else
49
+ raise Error, "#{@config.app_name} supervisor isn't running"
50
+ end
51
+ end
52
+
53
+ def stop_supervisor
54
+ if running?
55
+ puts "This will stop the supervisor only. Any processes that it started will no longer be managed."
56
+ puts "They will need to be stopped manually. \e[34mDo you wish to continue? (yes/NO)\e[0m"
57
+ if ['y', 'yes'].include?($stdin.gets.to_s.strip.downcase)
58
+ ::Process.kill('INT', current_pid)
59
+ puts "We've asked it to stop. It'll probably be done in a moment."
60
+ else
61
+ puts "OK. That's fine. You can just run `stop` to stop processes too."
62
+ end
63
+
64
+ else
65
+ raise Error, "#{@config.app_name} supervisor isn't running"
66
+ end
67
+ end
68
+
69
+ def restart
70
+ if running?
71
+ ::Process.kill('USR1', current_pid)
72
+ puts "Restarting #{@config.app_name}"
73
+ else
74
+ raise Error, "#{@config.app_name} supervisor isn't running"
75
+ end
76
+ end
77
+
78
+ def status
79
+ if running?
80
+ puts "#{@config.app_name} running (PID: #{current_pid})"
81
+ ::Process.kill('USR2', current_pid)
82
+ puts "Instance status details added to #{log_path}"
83
+ else
84
+ puts "#{@config.app_name} supervisor not running"
85
+ end
86
+ end
87
+
88
+ def kill
89
+ Dir[File.join(@config.pid_root, '*.pid')].each do |pid_path|
90
+ name = pid_path.split('/').last.gsub(/\.pid\z/, '')
91
+ pid = File.read(pid_path).to_i
92
+ begin
93
+ ::Process.kill('KILL', pid)
94
+ puts "Sent KILL to #{pid} (#{name})"
95
+ rescue Errno::ESRCH
96
+ end
97
+ FileUtils.rm(pid_path)
98
+ end
99
+ end
100
+
101
+ private
102
+
103
+ def running?
104
+ if pid = current_pid
105
+ ::Process.getpgid(pid) ? true : false
106
+ else
107
+ false
108
+ end
109
+ rescue Errno::ESRCH
110
+ false
111
+ end
112
+
113
+ def current_pid
114
+ if File.exist?(pid_path)
115
+ pid_file = File.read(pid_path).strip
116
+ pid_file.length > 0 ? pid_file.to_i : nil
117
+ else
118
+ nil
119
+ end
120
+ end
121
+
122
+ def pid_path
123
+ File.join(@config.pid_root, 'supervisor.pid')
124
+ end
125
+
126
+ def log_path
127
+ File.join(@config.log_root, 'supervisor.log')
128
+ end
129
+
130
+ end
131
+ end
@@ -0,0 +1,64 @@
1
+ require 'yaml'
2
+ require 'procodile/error'
3
+ require 'procodile/process'
4
+
5
+ module Procodile
6
+ class Config
7
+
8
+ COLORS = [35, 31, 36, 32, 33, 34]
9
+
10
+ attr_reader :root
11
+
12
+ def initialize(root)
13
+ @root = root
14
+ unless File.exist?(procfile_path)
15
+ raise Error, "Procfile not found at #{procfile_path}"
16
+ end
17
+
18
+ FileUtils.mkdir_p(pid_root)
19
+ FileUtils.mkdir_p(log_root)
20
+ end
21
+
22
+ def app_name
23
+ options['app_name'] || 'Procodile'
24
+ end
25
+
26
+ def processes
27
+ process_list.each_with_index.each_with_object({}) do |((name, command), index), hash|
28
+ options = {'log_color' => COLORS[index.divmod(COLORS.size)[1]]}.merge(process_options[name] || {})
29
+ hash[name] = Process.new(self, name, command, options)
30
+ end
31
+ end
32
+
33
+ def process_list
34
+ @processes ||= YAML.load_file(procfile_path)
35
+ end
36
+
37
+ def options
38
+ @options ||= File.exist?(options_path) ? YAML.load_file(options_path) : {}
39
+ end
40
+
41
+ def process_options
42
+ @process_options ||= options['processes'] || {}
43
+ end
44
+
45
+ def pid_root
46
+ File.expand_path(options['pid_root'] || 'pids', @root)
47
+ end
48
+
49
+ def log_root
50
+ File.expand_path(options['log_root'] || 'log', @root)
51
+ end
52
+
53
+ private
54
+
55
+ def procfile_path
56
+ File.join(@root, 'Procfile')
57
+ end
58
+
59
+ def options_path
60
+ File.join(@root, 'Procfile.options')
61
+ end
62
+
63
+ end
64
+ end
@@ -0,0 +1,4 @@
1
+ module Procodile
2
+ class Error < StandardError
3
+ end
4
+ end
@@ -0,0 +1,204 @@
1
+ require 'procodile/logger'
2
+
3
+ module Procodile
4
+ class Instance
5
+
6
+ attr_accessor :pid
7
+
8
+ def initialize(process, id)
9
+ @process = process
10
+ @id = id
11
+ @respawns = 0
12
+ end
13
+
14
+ def description
15
+ "#{@process.name}.#{@id}"
16
+ end
17
+
18
+ def dead?
19
+ @dead || false
20
+ end
21
+
22
+ def pid_file_path
23
+ File.join(@process.config.pid_root, "#{description}.pid")
24
+ end
25
+
26
+ def log_file_path
27
+ File.join(@process.config.log_root, "#{description}.log")
28
+ end
29
+
30
+ def pid_from_file
31
+ if File.exist?(pid_file_path)
32
+ pid = File.read(pid_file_path)
33
+ pid.length > 0 ? pid.strip.to_i : nil
34
+ else
35
+ nil
36
+ end
37
+ end
38
+
39
+ def running?(force_pid = nil)
40
+ if force_pid || @pid
41
+ ::Process.getpgid(force_pid || @pid) ? true : false
42
+ else
43
+ false
44
+ end
45
+ rescue Errno::ESRCH
46
+ false
47
+ end
48
+
49
+ def start
50
+ @stopping = false
51
+ Dir.chdir(@process.config.root) do
52
+ log_file = File.open(self.log_file_path, 'a')
53
+ @pid = ::Process.spawn({'PID_FILE' => pid_file_path}, @process.command, :out => log_file, :err => log_file)
54
+ Procodile.log(@process.log_color, description, "Started with PID #{@pid}")
55
+ File.open(pid_file_path, 'w') { |f| f.write(@pid.to_s + "\n") }
56
+ ::Process.detach(@pid)
57
+ end
58
+ end
59
+
60
+ #
61
+ # Is this instance supposed to be stopping/be stopped?
62
+ #
63
+ def stopping?
64
+ @stopping || false
65
+ end
66
+
67
+ #
68
+ # Send this signal the signal to stop and mark the instance in a state that
69
+ # tells us that we want it to be stopped.
70
+ #
71
+ def stop
72
+ @stopping = true
73
+ update_pid
74
+ if self.running?
75
+ pid = self.pid_from_file
76
+ Procodile.log(@process.log_color, description, "Sending TERM to #{pid}")
77
+ ::Process.kill('TERM', pid)
78
+ else
79
+ Procodile.log(@process.log_color, description, "Process already stopped")
80
+ end
81
+ end
82
+
83
+ #
84
+ # A method that will be called when this instance has been stopped and it isn't going to be
85
+ # started again
86
+ #
87
+ def on_stop
88
+ FileUtils.rm_f(self.pid_file_path)
89
+ Procodile.log(@process.log_color, description, "Removed PID file")
90
+ end
91
+
92
+ #
93
+ # Retarts the process using the appropriate method from the process configuraiton
94
+ #
95
+ def restart
96
+ Procodile.log(@process.log_color, description, "Restarting using #{@process.restart_mode} mode")
97
+ @restarting = true
98
+ update_pid
99
+ case @process.restart_mode
100
+ when 'usr1', 'usr2'
101
+ if running?
102
+ ::Process.kill(@process.restart_mode.upcase, @pid)
103
+ Procodile.log(@process.log_color, description, "Sent #{@process.restart_mode.upcase} signal to process #{@pid}")
104
+ else
105
+ Procodile.log(@process.log_color, description, "Process not running already. Starting it.")
106
+ start
107
+ end
108
+ when 'start-term'
109
+ old_process_pid = @pid
110
+ start
111
+ Procodile.log(@process.log_color, description, "Sent TERM signal to old PID #{old_process_pid} (forgetting now)")
112
+ ::Process.kill('TERM', old_process_pid)
113
+ when 'term-start'
114
+ stop
115
+ Thread.new do
116
+ # Wait for this process to stop and when it has, run it.
117
+ sleep 0.5 while running?
118
+ start
119
+ end
120
+ end
121
+ ensure
122
+ @restarting = false
123
+ end
124
+
125
+ #
126
+ # Update the locally cached PID from that stored on the file system.
127
+ #
128
+ def update_pid
129
+ pid_from_file = self.pid_from_file
130
+ if pid_from_file != @pid
131
+ @pid = pid_from_file
132
+ Procodile.log(@process.log_color, description, "PID file changed. Updated pid to #{@pid}")
133
+ true
134
+ else
135
+ false
136
+ end
137
+ end
138
+
139
+ #
140
+ # Check the status of this process and handle as appropriate
141
+ #
142
+ def check
143
+ # Don't do any checking if we're in the midst of a restart
144
+ return if @restarting
145
+
146
+ if self.running?
147
+ # Everything is OK. The process is running.
148
+ else
149
+ # If the process isn't running any more, update the PID in our memory from
150
+ # the file in case the process has changed itself.
151
+ return check if update_pid
152
+
153
+ if can_respawn?
154
+ Procodile.log(@process.log_color, description, "Process has stopped. Respawning...")
155
+ start
156
+ add_respawn
157
+ elsif respawns >= @process.max_respawns
158
+ Procodile.log(@process.log_color, description, "\e[41;37mWarning:\e[0m\e[31m this process has been respawned #{respawns} times and keeps dying.\e[0m")
159
+ Procodile.log(@process.log_color, description, "\e[31mIt will not be respawned automatically any longer and will no longer be managed.\e[0m")
160
+ die
161
+ end
162
+ end
163
+ end
164
+
165
+ #
166
+ # Mark this process as dead and tidy up after it
167
+ #
168
+ def die
169
+ on_stop
170
+ @dead = true
171
+ end
172
+
173
+ #
174
+ # Can this process be respawned if needed?
175
+ #
176
+ def can_respawn?
177
+ !stopping? && (respawns + 1) <= @process.max_respawns
178
+ end
179
+
180
+ #
181
+ # Return the number of times this process has been respawned in the last hour
182
+ #
183
+ def respawns
184
+ if @respawns.nil? || @last_respawn.nil? || @last_respawn < (Time.now - @process.respawn_window)
185
+ 0
186
+ else
187
+ @respawns
188
+ end
189
+ end
190
+
191
+ #
192
+ # Increment the counter of respawns for this process
193
+ #
194
+ def add_respawn
195
+ if @last_respawn && @last_respawn < (Time.now - @process.respawn_window)
196
+ @respawns = 1
197
+ else
198
+ @last_respawn = Time.now
199
+ @respawns += 1
200
+ end
201
+ end
202
+
203
+ end
204
+ end
@@ -0,0 +1,22 @@
1
+ require 'logger'
2
+ module Procodile
3
+
4
+ def self.mutex
5
+ @mutex ||= Mutex.new
6
+ end
7
+
8
+ def self.log(color, name, text)
9
+ mutex.synchronize do
10
+ text.to_s.lines.map(&:chomp).each do |message|
11
+ output = ""
12
+ output += "\e[#{color}m" if color
13
+ output += "#{Time.now.strftime("%H:%M:%S")} #{name.ljust(15, ' ')} |"
14
+ output += "\e[0m "
15
+ output += message
16
+ $stdout.puts output
17
+ $stdout.flush
18
+ end
19
+ end
20
+ end
21
+
22
+ end
@@ -0,0 +1,65 @@
1
+ require 'procodile/instance'
2
+
3
+ module Procodile
4
+ class Process
5
+
6
+ attr_reader :name
7
+ attr_reader :command
8
+ attr_reader :config
9
+
10
+ def initialize(config, name, command, options = {})
11
+ @config = config
12
+ @name = name
13
+ @command = command
14
+ @options = options
15
+ end
16
+
17
+ #
18
+ # Return the color for this process
19
+ #
20
+ def log_color
21
+ @options['log_color'] || 0
22
+ end
23
+
24
+ #
25
+ # How many instances of this process should be started
26
+ #
27
+ def quantity
28
+ @options['quantity'] || 1
29
+ end
30
+
31
+ #
32
+ # The maximum number of times this process can be respawned in the given period
33
+ #
34
+ def max_respawns
35
+ @options['max_respawns'] ? @options['max_respawns'].to_i : 5
36
+ end
37
+
38
+ #
39
+ # The respawn window. One hour by default.
40
+ #
41
+ def respawn_window
42
+ @options['respawn_window'] ? @options['respawn_window'].to_i : 3600
43
+ end
44
+
45
+ #
46
+ # Defines how this process should be restarted
47
+ #
48
+ # start-term = start new instances and send term to children
49
+ # usr1 = just send a usr1 signal to the current instance
50
+ # usr2 = just send a usr2 signal to the current instance
51
+ # term-start = stop the old instances, when no longer running, start a new one
52
+ #
53
+ def restart_mode
54
+ @options['restart_mode'] || 'term-start'
55
+ end
56
+
57
+ #
58
+ # Generate an array of new instances for this process (based on its quantity)
59
+ #
60
+ def generate_instances
61
+ quantity.times.map { |i| Instance.new(self, i + 1) }
62
+ end
63
+
64
+ end
65
+ end
@@ -0,0 +1,32 @@
1
+ module Procodile
2
+ class SignalHandler
3
+
4
+ def self.queue
5
+ Thread.main[:signal_queue] ||= []
6
+ end
7
+
8
+ def initialize(*signals)
9
+ @handlers = {}
10
+ Thread.new do
11
+ loop do
12
+ if signal = self.class.queue.shift
13
+ if @handlers[signal]
14
+ @handlers[signal].each(&:call)
15
+ end
16
+ end
17
+ sleep 1
18
+ end
19
+ end
20
+
21
+ signals.each do |sig|
22
+ Signal.trap(sig, proc { SignalHandler.queue << sig })
23
+ end
24
+ end
25
+
26
+ def register(name, &block)
27
+ @handlers[name] ||= []
28
+ @handlers[name] << block
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,96 @@
1
+ module Procodile
2
+ class Supervisor
3
+
4
+ # Create a new supervisor instance that will be monitoring the
5
+ # processes that have been provided.
6
+ def initialize(config)
7
+ @config = config
8
+ @instances = []
9
+ end
10
+
11
+ def start
12
+ Procodile.log nil, "system", "#{@config.app_name} supervisor started with PID #{::Process.pid}"
13
+ @config.processes.each do |name, process|
14
+ process.generate_instances.each do |instance|
15
+ instance.start
16
+ @instances << instance
17
+ end
18
+ end
19
+ supervise
20
+ end
21
+
22
+ def stop
23
+ return if @stopping
24
+ @stopping = true
25
+ Procodile.log nil, "system", "Stopping all #{@config.app_name} processes"
26
+ @instances.each(&:stop)
27
+ end
28
+
29
+ def stop_supervisor
30
+ Procodile.log nil, 'system', "Stopping #{@config.app_name} supervisor"
31
+ FileUtils.rm_f(File.join(@config.pid_root, 'supervisor.pid'))
32
+ ::Process.exit 0
33
+ end
34
+
35
+ def restart
36
+ Procodile.log nil, 'system', "Restarting all #{@config.app_name} processes"
37
+ @instances.each(&:restart)
38
+ end
39
+
40
+ def status
41
+ Procodile.log '37;44', 'status', "Status as at: #{Time.now.utc.to_s}"
42
+ @instances.each do |instance|
43
+ if instance.running?
44
+ Procodile.log '37;44', 'status', "#{instance.description} is RUNNING (pid #{instance.pid}). Respawned #{instance.respawns} time(s)"
45
+ else
46
+ Procodile.log '37;44', 'status', "#{instance.description} is STOPPED"
47
+ end
48
+ end
49
+ end
50
+
51
+ def supervise
52
+ loop do
53
+ # Tidy up any instances that we no longer wish to be managed. They will
54
+ # be removed from the list.
55
+ remove_dead_instances
56
+
57
+ if @stopping
58
+ # If the system is stopping, we'll remove any instances that have already
59
+ # stopped and trigger their on_stop callback.
60
+ remove_stopped_instances
61
+
62
+ # When all the instances we manage have gone away, we can stop ourself.
63
+ if @instances.size > 0
64
+ Procodile.log nil, "system", "Waiting for #{@instances.size} processes to stop"
65
+ else
66
+ Procodile.log nil, "system", "All processes have stopped"
67
+ stop_supervisor
68
+ end
69
+ else
70
+ # Check all instances that we manage and let them do their things.
71
+ @instances.each(&:check)
72
+ end
73
+
74
+ sleep 5
75
+ end
76
+ end
77
+
78
+ private
79
+
80
+ def remove_dead_instances
81
+ @instances.reject!(&:dead?)
82
+ end
83
+
84
+ def remove_stopped_instances
85
+ @instances.reject! do |instance|
86
+ if instance.running?
87
+ false
88
+ else
89
+ instance.on_stop
90
+ true
91
+ end
92
+ end
93
+ end
94
+
95
+ end
96
+ end
metadata ADDED
@@ -0,0 +1,56 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: procodile
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.1
5
+ platform: ruby
6
+ authors:
7
+ - Adam Cooke
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2016-10-16 00:00:00.000000000 Z
12
+ dependencies: []
13
+ description: Run Ruby/Rails processes in the background on Linux servers with ease.
14
+ email:
15
+ - me@adamcooke.io
16
+ executables:
17
+ - procodile
18
+ extensions: []
19
+ extra_rdoc_files: []
20
+ files:
21
+ - bin/procodile
22
+ - lib/procodile.rb
23
+ - lib/procodile/cli.rb
24
+ - lib/procodile/config.rb
25
+ - lib/procodile/error.rb
26
+ - lib/procodile/instance.rb
27
+ - lib/procodile/logger.rb
28
+ - lib/procodile/process.rb
29
+ - lib/procodile/signal_handler.rb
30
+ - lib/procodile/supervisor.rb
31
+ homepage: https://github.com/adamcooke/procodile
32
+ licenses:
33
+ - MIT
34
+ metadata: {}
35
+ post_install_message:
36
+ rdoc_options: []
37
+ require_paths:
38
+ - lib
39
+ required_ruby_version: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ required_rubygems_version: !ruby/object:Gem::Requirement
45
+ requirements:
46
+ - - ">="
47
+ - !ruby/object:Gem::Version
48
+ version: '0'
49
+ requirements: []
50
+ rubyforge_project:
51
+ rubygems_version: 2.5.1
52
+ signing_key:
53
+ specification_version: 4
54
+ summary: This gem will help you run Ruby processes from a Procfile on Linux servers
55
+ in the background.
56
+ test_files: []