epi 0.0.1
Sign up to get free protection for your applications and to get access to all the features.
- 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
|