epi 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,10 @@
1
+ require_relative 'exceptions/base'
2
+ require_relative 'exceptions/fatal'
3
+ require_relative 'exceptions/shutdown'
4
+ require_relative 'exceptions/invalid_configuration_file'
5
+
6
+ module Epi
7
+ module Exceptions
8
+
9
+ end
10
+ end
@@ -0,0 +1,7 @@
1
+ module Epi
2
+ module Exceptions
3
+ class Base < RuntimeError
4
+
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,12 @@
1
+ module Epi
2
+ module Exceptions
3
+ class Fatal < Base
4
+
5
+ def initialize(*args)
6
+ super *args
7
+ Epi.logger.fatal message
8
+ end
9
+
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ module Epi
2
+ module Exceptions
3
+ class InvalidConfigurationFile < Base
4
+
5
+ attr_reader :data
6
+
7
+ def initialize(message, data)
8
+ super message
9
+ @data = data
10
+ end
11
+
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,7 @@
1
+ module Epi
2
+ module Exceptions
3
+ class Shutdown < Base
4
+
5
+ end
6
+ end
7
+ end
data/lib/epi/job.rb ADDED
@@ -0,0 +1,110 @@
1
+ require 'forwardable'
2
+
3
+ module Epi
4
+ class Job
5
+ extend Forwardable
6
+
7
+ attr_reader :job_description
8
+ attr_accessor :expected_count
9
+
10
+ delegate [:name, :id, :allowed_processes] => :job_description
11
+
12
+ def logger
13
+ Epi.logger
14
+ end
15
+
16
+ def initialize(job_description, state)
17
+ @job_description = job_description
18
+ @expected_count = state['expected_count'] || job_description.initial_processes
19
+ @pids = state['pids']
20
+ @dying_pids = state['dying_pids']
21
+ end
22
+
23
+ # noinspection RubyStringKeysInHashInspection
24
+ def state
25
+ {
26
+ 'expected_count' => expected_count,
27
+ 'pids' => pids,
28
+ 'dying_pids' => dying_pids
29
+ }
30
+ end
31
+
32
+ # Get a hash of PIDs, with internal process IDs as keys and PIDs as values
33
+ # @example `{'1a2v3c4d' => 4820}`
34
+ # @return [Hash]
35
+ def pids
36
+ @pids ||= {}
37
+ end
38
+
39
+ # Get a hash of PIDs, with internal process IDs as keys and PIDs as values,
40
+ # for process that are dying
41
+ # @example `{'1a2v3c4d' => 4820}`
42
+ # @return [Hash]
43
+ def dying_pids
44
+ @dying_pids ||= {}
45
+ end
46
+
47
+ # Get the data key for the PID file of the given process ID or PID
48
+ # @param [String|Fixnum] proc_id Example: `'1a2b3c4d'` or `1234`
49
+ # @return [String|NilClass] Example: `pids/job_id/1ab3c4d.pid`, or `nil` if not found
50
+ def pid_key(proc_id)
51
+ proc_id = pids.key(proc_id) if Fixnum === proc_id
52
+ proc_id && job_description.pid_key(proc_id)
53
+ end
54
+
55
+ # Stops processes that shouldn't run, starts process that should run, and
56
+ # fires event handlers
57
+ def sync!
58
+
59
+ # Remove non-running PIDs from the list
60
+ pids.reject { |_, pid| ProcessStatus.pids.include? pid }.each do |proc_id, pid|
61
+ logger.debug "Lost process #{pid}"
62
+ pids.delete proc_id
63
+ end
64
+
65
+ # Remove non-running PIDs from the dying list. This is just in case
66
+ # the daemon crashed before it was able to clean up a dying worker
67
+ # (i.e. it sent a TERM but didn't get around to sending a KILL)
68
+ dying_pids.select! { |_, pid| ProcessStatus.pids.include? pid }
69
+
70
+ # TODO: clean up processes that never died how they should have
71
+
72
+ # Run new processes
73
+ start_one while running_count < expected_count
74
+
75
+ # Kill old processes
76
+ stop_one while running_count > expected_count
77
+ end
78
+
79
+ def terminate!
80
+ self.expected_count = 0
81
+ sync!
82
+ end
83
+
84
+ def running_count
85
+ pids.count
86
+ end
87
+
88
+ private
89
+
90
+ def start_one
91
+ proc_id, pid = job_description.launch
92
+ pids[proc_id] = pid
93
+ Data.write pid_key(proc_id), pid
94
+ end
95
+
96
+ def stop_one
97
+ proc_id, pid = pids.shift
98
+ dying_pids[proc_id] = pid
99
+ work = proc do
100
+ ProcessStatus[pid].kill job_description.kill_timeout
101
+ end
102
+ done = proc do
103
+ dying_pids.delete proc_id
104
+ Data.write pid_key(proc_id), nil
105
+ end
106
+ EventMachine.defer work, done
107
+ end
108
+
109
+ end
110
+ end
@@ -0,0 +1,107 @@
1
+ require 'shellwords'
2
+ require 'securerandom'
3
+
4
+ module Epi
5
+ class JobDescription
6
+
7
+ def self.property(method, default = nil, &validator)
8
+ define_method method do
9
+ @props.key?(method) ? @props[method] : default
10
+ end
11
+ define_method :"#{method}=" do |value|
12
+ if validator
13
+ result = validator.call(value)
14
+ raise Exceptions::Fatal, "Invalid value '#{value}' of type #{value.class.name} for #{method}: #{result}" if result
15
+ end
16
+ @props[method] = value
17
+ end
18
+ end
19
+
20
+ property :name do |value|
21
+ 'Must be a non-blank string' unless String === value && !value.strip.empty?
22
+ end
23
+
24
+ property :directory do |value|
25
+ 'Must be a valid relative or absolute directory path' unless String === value && value =~ /\A[^\0]+\z/
26
+ end
27
+
28
+ property :environment, {} do |value|
29
+ 'Must be a hash' unless Hash === value
30
+ end
31
+
32
+ property :command do |value|
33
+ 'Must be a non-blank string' unless String === value && !value.strip.empty?
34
+ end
35
+
36
+ property :initial_processes, 1 do |value|
37
+ 'Must be a non-negative integer' unless Fixnum === value && value >= 0
38
+ end
39
+
40
+ property :allowed_processes, 0..10 do |value|
41
+ 'Must be a range including a positive integer, and no negatives' unless
42
+ Range === value && value.max >= 1 && value.min >= 0
43
+ end
44
+
45
+ %i[stdout stderr].each do |pipe|
46
+ property pipe do |value|
47
+ 'Must be a path to a file descriptor' unless String === value && value =~ /\A[^\0]+\z/
48
+ end
49
+ end
50
+
51
+ property :user do |value|
52
+ 'Must be a non-blank string' unless String === value && !value.strip.empty?
53
+ end
54
+
55
+ property :kill_timeout, 20 do |value|
56
+ 'Must be a non-negative number' unless value.is_a?(Numeric) && value >= 0
57
+ end
58
+
59
+ attr_reader :id
60
+
61
+ def initialize(id)
62
+ @id = id
63
+ @handlers = {}
64
+ @props = {}
65
+ end
66
+
67
+ def launch
68
+ proc_id = generate_id
69
+ opts = {
70
+ cwd: directory,
71
+ user: user,
72
+ env: {PIDFILE: pid_path(proc_id)}.merge(environment || {}),
73
+ stdout: stdout,
74
+ stderr: stderr
75
+ }
76
+ pid = Epi.launch command, **opts
77
+ [proc_id, pid]
78
+ end
79
+
80
+ def reconfigure
81
+ @handlers = {}
82
+ yield self
83
+ end
84
+
85
+ def on(event, *args, &handler)
86
+ (@handlers[event] ||= []) << {
87
+ args: args,
88
+ handler: handler
89
+ }
90
+ end
91
+
92
+ def pid_key(proc_id)
93
+ "pids/#{id}/#{proc_id}.pid"
94
+ end
95
+
96
+ def pid_path(proc_id)
97
+ Data.home + pid_key(proc_id)
98
+ end
99
+
100
+ private
101
+
102
+ def generate_id
103
+ SecureRandom.hex 4
104
+ end
105
+
106
+ end
107
+ end
data/lib/epi/jobs.rb ADDED
@@ -0,0 +1,83 @@
1
+ require 'forwardable'
2
+
3
+ module Epi
4
+
5
+ # Manages running jobs
6
+ module Jobs
7
+
8
+ class << self
9
+ extend Forwardable
10
+
11
+ delegate [:[], :[]=, :delete, :each_value, :map] => :@jobs
12
+
13
+ attr_reader :configuration_files
14
+
15
+ def reset!
16
+ @configuration_files = {}
17
+ @jobs = {}
18
+ end
19
+
20
+ def beat!
21
+
22
+ # Cancel any scheduled beats
23
+ EventMachine.cancel_timer @next_beat if @next_beat
24
+
25
+ # Make sure configuration files have been read
26
+ refresh_config!
27
+
28
+ # Snapshot currently running processes
29
+ ProcessStatus.take!
30
+
31
+ # Get rid of jobs for config files that have been removed
32
+ clean_configuration_files!
33
+
34
+ # Create new jobs
35
+ make_new_jobs!
36
+
37
+ # Sync each job with its expectations
38
+ each_value &:sync!
39
+
40
+ # Write state of each job to data file
41
+ Data.jobs = map { |id, job| [id.to_s, job.state] }.to_h
42
+ Data.save
43
+
44
+ # Schedule the next beat
45
+ @next_beat = EventMachine.add_timer(5) { beat! } # TODO: make interval configurable
46
+ end
47
+
48
+ def job_descriptions
49
+ configuration_files.values.inject({}) { |all, conf_file| all.merge! conf_file.job_descriptions }
50
+ end
51
+
52
+ def refresh_config!
53
+ Data.configuration_paths.each do |path|
54
+ configuration_files[path] ||= ConfigurationFile.new(path)
55
+ end
56
+ configuration_files.each_value &:read
57
+ end
58
+
59
+ private
60
+
61
+ def clean_configuration_files!
62
+ to_remove = configuration_files.keys - Data.configuration_paths
63
+ to_remove.each do |path|
64
+ configuration_files.delete(path).job_descriptions.each_key do |job_id|
65
+ job = delete(job_id)
66
+ job.terminate! if job
67
+ end
68
+ end
69
+ end
70
+
71
+ def make_new_jobs!
72
+ job_descriptions.each do |name, description|
73
+ self[name] ||= Epi::Job.new(description, Data.jobs[name] || {})
74
+ end
75
+ end
76
+
77
+ end
78
+
79
+ # Set up class variables
80
+ reset!
81
+
82
+ end
83
+ end
data/lib/epi/launch.rb ADDED
@@ -0,0 +1,59 @@
1
+ module Epi
2
+ # Run a system command in the background, and allow it to keep running
3
+ # after Ruby has exited.
4
+ # @param command [String|Array] The command to run, either as a pre-escaped string,
5
+ # or an array of non-escaped strings (a command and zero or more arguments).
6
+ # @param env [Hash] Environment variables to be passed to the command, in addition
7
+ # to those inherited from the current environment
8
+ # @param user [String|NilClass] If supplied, command will be run through `su` as
9
+ # this user.
10
+ # @param cwd [String|NilClass] If supplied, command will be run from this
11
+ # directory.
12
+ # @param stdout [String|TrueClass|FalseClass|NilClass] Where to redirect standard
13
+ # output; either a file path, or `true` for no redirection, or `false`/`nil` to
14
+ # redirect to `/dev/null`
15
+ # @param stderr [String|TrueClass|FalseClass|NilClass] Where to redirect standard
16
+ # error; either a file path, or `true` for no redirection, or `false`/`nil` to
17
+ # redirect to `/dev/null`
18
+ # @return [Fixnum] The PID of the started process
19
+ def self.launch(command, env: {}, user: nil, cwd: nil, stdout: true, stderr: true)
20
+
21
+ # Prevent hang-up
22
+ cmd = 'nohup '
23
+
24
+ # The main command and its arguments
25
+ if String === command
26
+
27
+ # Pre-escaped string
28
+ cmd << command
29
+ else
30
+
31
+ # Command and arguments that need to be escaped
32
+ command.each { |part| cmd << ' ' << Shellwords.escape(part) }
33
+ end
34
+
35
+ # Include `su` command if a user is given
36
+ cmd = "su #{user} -c #{cmd}" if user
37
+
38
+ # STDOUT and STDERR redirection
39
+ {:>> => stdout, :'2>>' => stderr}.each do |arrow, dest|
40
+ cmd << " #{arrow} #{dest || '/dev/null'}" unless TrueClass === dest
41
+ end
42
+
43
+ # Run in background, and return PID of backgrounded process
44
+ cmd << ' & echo $!'
45
+
46
+ # Include the working directory
47
+ cmd = "cd #{Shellwords.escape cwd} && (#{cmd})" if cwd
48
+
49
+ # Convert environment variables to strings, and merge them with the current environment
50
+ env = ENV.to_h.merge(env).map { |k, v| [k.to_s, v.to_s] }.to_h
51
+
52
+ logger.debug "Starting `#{cmd}`"
53
+
54
+ # Run the command and read the resulting PID from its STDOUT
55
+ IO.popen(env, cmd) { |p| p.read }.to_i.tap do |pid|
56
+ logger.debug "Process #{pid} started: #{`ps -p #{pid} -o command=`.chomp}"
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,71 @@
1
+ require 'forwardable'
2
+
3
+ module Epi
4
+ class ProcessStatus
5
+
6
+ class << self
7
+ extend Forwardable
8
+
9
+ def reset!
10
+ @last = nil
11
+ end
12
+
13
+ # Current running processes
14
+ # @return [self]
15
+ def now
16
+ new
17
+ end
18
+
19
+ # Take a snapshot of current running processes
20
+ # @return [self]
21
+ def take!
22
+ @last = now
23
+ end
24
+
25
+ # The last snapshot taken by {#take}
26
+ # @return [self]
27
+ def last
28
+ @last ||= take!
29
+ end
30
+
31
+ delegate [:[], :pids] => :last
32
+
33
+ end
34
+
35
+ # Lookup a running process by its PID
36
+ # @param pid [String|Numeric] PID of the process to lookup
37
+ # @return [RunningProcess|NilClass]
38
+ def [](pid)
39
+ pid = pid.to_i
40
+ @running_processes[pid] ||= find_by_pid(pid)
41
+ end
42
+
43
+ # Get a list of PIDs of running processes
44
+ # @return [Array] An array of PIDs as `Fixnum`s
45
+ def pids
46
+ @pids ||= @lines.keys
47
+ end
48
+
49
+ private
50
+
51
+ def initialize
52
+
53
+ # Cached running processes
54
+ @running_processes = {}
55
+
56
+ # Run `ps`
57
+ result = %x(ps x -o #{RunningProcess::PS_FORMAT})
58
+
59
+ # Split into lines, and get rid of the first (heading) line
60
+ @lines = result.lines[1..-1].map { |line| [line.lstrip.split(/\s/, 2).first.to_i, line] }.to_h
61
+
62
+ end
63
+
64
+ def find_by_pid(pid)
65
+ line = @lines[pid]
66
+ RunningProcess.new(pid, line) if line
67
+ end
68
+
69
+
70
+ end
71
+ end