epi 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.
@@ -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