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.
- checksums.yaml +7 -0
- data/bin/epi +16 -0
- data/lib/epi.rb +52 -0
- data/lib/epi/cli.rb +22 -0
- data/lib/epi/cli/command.rb +27 -0
- data/lib/epi/cli/commands/config.rb +28 -0
- data/lib/epi/cli/commands/job.rb +19 -0
- data/lib/epi/cli/commands/server.rb +38 -0
- data/lib/epi/cli/commands/status.rb +13 -0
- data/lib/epi/configuration_file.rb +56 -0
- data/lib/epi/core_ext.rb +1 -0
- data/lib/epi/core_ext/inflector.rb +11 -0
- data/lib/epi/data.rb +152 -0
- data/lib/epi/exceptions.rb +10 -0
- data/lib/epi/exceptions/base.rb +7 -0
- data/lib/epi/exceptions/fatal.rb +12 -0
- data/lib/epi/exceptions/invalid_configuration_file.rb +14 -0
- data/lib/epi/exceptions/shutdown.rb +7 -0
- data/lib/epi/job.rb +110 -0
- data/lib/epi/job_description.rb +107 -0
- data/lib/epi/jobs.rb +83 -0
- data/lib/epi/launch.rb +59 -0
- data/lib/epi/process_status.rb +71 -0
- data/lib/epi/running_process.rb +159 -0
- data/lib/epi/server.rb +104 -0
- data/lib/epi/server/receiver.rb +46 -0
- data/lib/epi/server/responder.rb +44 -0
- data/lib/epi/server/responders/command.rb +15 -0
- data/lib/epi/server/responders/config.rb +30 -0
- data/lib/epi/server/responders/job.rb +70 -0
- data/lib/epi/server/responders/shutdown.rb +15 -0
- data/lib/epi/server/responders/status.rb +52 -0
- data/lib/epi/server/sender.rb +64 -0
- data/lib/epi/version.rb +3 -0
- metadata +106 -0
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
|