pazuzu 0.1.2

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,30 @@
1
+ module Pazuzu
2
+ module Control
3
+
4
+ class SocketClient
5
+
6
+ def initialize(path)
7
+ @path = path
8
+ @socket = UNIXSocket.new(@path)
9
+ end
10
+
11
+ def execute(command, *arguments)
12
+ command_line = [command, *arguments].join(' ')
13
+ command_line << "\n"
14
+
15
+ @socket.write(command_line)
16
+
17
+ num_bytes = @socket.read(10).to_i(16)
18
+ if num_bytes > 0
19
+ data = @socket.read(num_bytes)
20
+ return JSON.parse(data)
21
+ end
22
+ end
23
+
24
+ attr_reader :path
25
+
26
+ end
27
+
28
+ end
29
+
30
+ end
@@ -0,0 +1,75 @@
1
+ module Pazuzu
2
+ module Control
3
+
4
+ class SocketServer
5
+
6
+ include Utility::Runnable
7
+
8
+ def initialize(supervisor, path)
9
+ @supervisor = supervisor
10
+ @path = path
11
+ @logger = Utility::AnnotatedLogger.new(
12
+ supervisor.logger, 'Command socket server')
13
+ super()
14
+ end
15
+
16
+ attr_reader :path
17
+ attr_reader :supervisor
18
+
19
+ protected
20
+
21
+ def listen
22
+ unless @socket
23
+ File.unlink(@path) if File.exist?(@path) and File.socket?(@path)
24
+ @server = UNIXServer.new(@path)
25
+ end
26
+ end
27
+
28
+ def run_protocol
29
+ while @server
30
+ begin
31
+ socket = @server.accept
32
+ if socket
33
+ Thread.start {
34
+ begin
35
+ protocol = Protocol.new(socket, @supervisor)
36
+ protocol.run!
37
+ rescue Errno::EPIPE, Errno::ECONNRESET
38
+ # Ignore
39
+ rescue Exception => e
40
+ @logger.error "Exception during command protocol exchange: #{e.class}: #{e}"
41
+ ensure
42
+ socket.close
43
+ end
44
+ }
45
+ end
46
+ rescue Exception => e
47
+ @logger.error "Socket server exception: #{e.class}: #{e}"
48
+ sleep(0.1)
49
+ end
50
+ end
51
+ end
52
+
53
+ def on_starting
54
+ listen
55
+ @thread = Thread.start { run_protocol }
56
+ runnable_state.started!
57
+ end
58
+
59
+ def on_stopping
60
+ if @socket
61
+ @socket.close rescue nil
62
+ @socket = nil
63
+ end
64
+ if @thread
65
+ @thread.terminate
66
+ @thread = nil
67
+ end
68
+ @server = nil
69
+ runnable_state.stopped!
70
+ end
71
+
72
+ end
73
+
74
+ end
75
+ end
@@ -0,0 +1,218 @@
1
+ module Pazuzu
2
+
3
+ class Instance
4
+
5
+ # Time that we give processes to shut down normally on SIGTERM.
6
+ SHUTDOWN_GRACE_TIME = 10.seconds
7
+
8
+ include Utility::Runnable
9
+
10
+ def initialize(worker, index, root_path, command_line)
11
+ @worker = worker
12
+ @index = index
13
+ @logger = Utility::AnnotatedLogger.new(
14
+ @worker.application.supervisor.logger, qname)
15
+ @root_path = root_path
16
+ @command_line = command_line
17
+ @tailer = Utility::OutputTailer.new(
18
+ :limit => 5000,
19
+ :on_line => proc { |timestamp, line| @logger.info("[output] #{line}") })
20
+ @flap_limiter = Utility::RateLimiter.new(0.1)
21
+ @recovery_count = 0
22
+ @cgroup = @worker.application.supervisor.cgroup_for_instance(self)
23
+ super()
24
+ end
25
+
26
+ # Does this instance have any unattached PIDs running?
27
+ def attachable?
28
+ run_state == :stopped && @cgroup.pids.any?
29
+ end
30
+
31
+ def qname
32
+ [@worker.qname, @index].join('.')
33
+ end
34
+
35
+ def log_entries
36
+ source = qname
37
+ return @tailer.entries.map { |(time, message)| [source, time, message] }
38
+ end
39
+
40
+ attr_reader :command_line
41
+ attr_reader :worker
42
+ attr_reader :index
43
+ attr_reader :root_path
44
+ attr_reader :pid
45
+ attr_reader :recovery_count
46
+
47
+ private
48
+
49
+ def on_starting
50
+ start_process!
51
+ end
52
+
53
+ def on_started
54
+ @logger.info 'Started'
55
+ end
56
+
57
+ def on_stopping
58
+ pids = @cgroup.pids
59
+ if pids.any?
60
+ if signal!('TERM')
61
+ @termination_deadline = Time.now + SHUTDOWN_GRACE_TIME
62
+ else
63
+ process_lost!
64
+ end
65
+ else
66
+ runnable_state.stopped!
67
+ end
68
+ end
69
+
70
+ def on_stopped
71
+ @recovery_count = 0
72
+ @logger.info "Stopped"
73
+ end
74
+
75
+ def on_failed
76
+ end
77
+
78
+ def signal!(signal_name)
79
+ any_signaled = false
80
+ @cgroup.pids.each do |pid|
81
+ begin
82
+ Process.kill(signal_name, pid)
83
+ rescue Errno::ESRCH
84
+ else
85
+ @logger.info "Signaled instance PID=#{pid} with SIG#{signal_name}"
86
+ any_signaled = true
87
+ end
88
+ end
89
+ any_signaled
90
+ end
91
+
92
+ def start_process!
93
+ if @cgroup.pids.empty?
94
+ unless @flap_limiter.count!
95
+ return unless [:starting, :running].include?(run_state)
96
+ @logger.warn "Restarting too fast, process may be flapping"
97
+ end
98
+
99
+ reset_process!
100
+
101
+ @logger.info "Spawning process with command (in #{@root_path}): #{@command_line}"
102
+
103
+ stream = @tailer.open
104
+ pid = Process.fork do
105
+ exec!(stream)
106
+ end
107
+ stream.close
108
+
109
+ @logger.info "Spawned process with PID=#{pid}"
110
+ end
111
+
112
+ @spawned_at = Time.now
113
+ @monitor_thread ||= Thread.start { monitor_process }
114
+ end
115
+
116
+ def exec!(io_out)
117
+ @worker.setup_spawned_process!
118
+ Process.setsid
119
+
120
+ # Double fork to detach and avoid zombies
121
+ Process.fork do
122
+ Process.setsid
123
+
124
+ Dir.chdir(@root_path)
125
+
126
+ # TODO: Log to file so we can tail
127
+ $stderr.reopen("/tmp/#{qname}")
128
+ $stdout.reopen("/tmp/#{qname}")
129
+ #$stderr.reopen(io_out)
130
+ #$stdout.reopen(io_out)
131
+
132
+ begin
133
+ @cgroup.exec(@command_line)
134
+ rescue Errno::ENOENT => e
135
+ abort e.message
136
+ end
137
+ Process.exit(0)
138
+ end
139
+
140
+ # Never gets here, exit purely for aesthetic and/or superstitious reasons
141
+ Process.exit(1)
142
+ end
143
+
144
+ def process_lost!
145
+ @logger.info "Child process lost, assuming it stopped on its own"
146
+ process_stopped!
147
+ end
148
+
149
+ def process_stopped!
150
+ reset_process!
151
+ case run_state
152
+ when :running, :starting
153
+ @recovery_count += 1
154
+ @logger.info "Respawning after failure"
155
+ if run_state == :running
156
+ runnable_state.starting!
157
+ else
158
+ start_process!
159
+ end
160
+ when :stopping
161
+ runnable_state.stopped!
162
+ end
163
+ end
164
+
165
+ def process_confirmed_running!
166
+ if @termination_deadline and @termination_deadline <= Time.now
167
+ begin
168
+ Process.kill('KILL', @pid)
169
+ @logger.info "Child did not terminate in time, forcibly killing with SIGKILL"
170
+ rescue Errno::ESRCH
171
+ @logger.info "Child process lost, assuming it stopped on its own"
172
+ process_stopped!
173
+ else
174
+ @termination_deadline = Time.now + SHUTDOWN_GRACE_TIME
175
+ end
176
+ else
177
+ if run_state == :starting
178
+ @logger.info "Child seems to be running"
179
+ runnable_state.started!
180
+ end
181
+ end
182
+ end
183
+
184
+ def reset_process!
185
+ @spawned_at = nil
186
+ @tailer.close
187
+ @termination_deadline = nil
188
+ end
189
+
190
+ def monitor_process
191
+ while [:starting, :running, :stopping].include?(run_state)
192
+ begin
193
+ case run_state
194
+ when :starting
195
+ if @cgroup.pids.any?
196
+ process_confirmed_running!
197
+ end
198
+ when :running
199
+ if @cgroup.pids.empty?
200
+ process_lost!
201
+ end
202
+ when :stopping
203
+ if @cgroup.pids.empty?
204
+ runnable_state.stopped!
205
+ end
206
+ end
207
+ sleep(2)
208
+ rescue Exception => e
209
+ @logger.error(e.message)
210
+ end
211
+ end
212
+ ensure
213
+ @monitor_thread = nil
214
+ end
215
+
216
+ end
217
+
218
+ end
@@ -0,0 +1,16 @@
1
+ module Pazuzu
2
+
3
+ module Procfiles
4
+
5
+ def self.normalize_procfile_path(path)
6
+ path = File.expand_path(path)
7
+ if File.directory?(path)
8
+ automatic_path = File.join(path, 'Procfile')
9
+ path = automatic_path if File.exist?(automatic_path)
10
+ end
11
+ path
12
+ end
13
+
14
+ end
15
+
16
+ end
@@ -0,0 +1,201 @@
1
+ module Pazuzu
2
+
3
+ class ConfigurationError < StandardError; end
4
+
5
+ # The supervisor controls the different applications registered with it.
6
+ class Supervisor
7
+
8
+ DEFAULT_SOCKET_PATH = '/var/run/pazuzud.socket'
9
+
10
+ include Utility::Runnable
11
+
12
+ def initialize(options)
13
+ options.assert_valid_keys(:config_path)
14
+ @logger = Logger.new($stderr)
15
+ @logger.level = Logger::WARN
16
+ @applications = Utility::RunnablePool.new
17
+ @config_path = options[:config_path]
18
+ Thread.start {
19
+ # Wait for PIDs, otherwise we will get zombies
20
+ loop do
21
+ begin
22
+ Process.waitpid
23
+ rescue Errno::ECHILD
24
+ sleep(1)
25
+ rescue
26
+ # Ignore
27
+ end
28
+ end
29
+ }
30
+ super()
31
+ end
32
+
33
+ def run!
34
+ load_configuration!
35
+ start!
36
+ while [:starting, :running].include?(run_state)
37
+ sleep(1)
38
+ end
39
+ end
40
+
41
+ def load_configuration!
42
+ configure!(load_config_from_yaml(@config_path))
43
+ end
44
+
45
+ def configure!(configuration)
46
+ case configuration['log_path']
47
+ when 'syslog'
48
+ @logger = SyslogLogger('pazuzu')
49
+ when nil
50
+ @logger = Logger.new($stderr)
51
+ else
52
+ @logger = Logger.new(configuration['log_path'])
53
+ end
54
+ @logger.level = Logger::DEBUG
55
+
56
+ new_socket_path = configuration['socket_path']
57
+ new_socket_path ||= DEFAULT_SOCKET_PATH
58
+ if @socket_path and new_socket_path != @socket_path
59
+ @logger.warn("Cannot change socket path after start")
60
+ else
61
+ @socket_path = new_socket_path
62
+ end
63
+
64
+ @socket_server ||= Control::SocketServer.new(self, @socket_path)
65
+
66
+ leftover_applications = @applications.children.dup
67
+ (configuration['applications'] || {}).each do |name, app_config|
68
+ if app_config
69
+ name = name.to_s
70
+ application = @applications.children.select { |a| a.name == name }.first
71
+ if application
72
+ leftover_applications.delete(application)
73
+ else
74
+ @logger.info("Adding application #{name}")
75
+ application = Application.new(self, name)
76
+ @applications.register(application)
77
+ end
78
+ application.configure!(app_config['procfile'], app_config)
79
+ end
80
+ end
81
+ leftover_applications.each do |application|
82
+ @logger.info("Removing application #{name}")
83
+ @applications.unregister(application)
84
+ end
85
+
86
+ cgroups_config = configuration['cgroups'] || {}
87
+
88
+ new_cgroup_hiearchy_root = cgroups_config['hieararchy_root']
89
+ new_cgroup_hiearchy_root ||= 'pazuzu'
90
+ new_cgroup_hiearchy_root.gsub!(/^\/+/, '')
91
+ new_cgroup_hiearchy_root.gsub!(/\/$/, '')
92
+ if @cgroup_hieararchy_root and new_cgroup_hiearchy_root != @cgroup_hieararchy_root
93
+ @logger.warn("Cannot change cgroups hiearchy root after start")
94
+ else
95
+ @cgroup_hieararchy_root = new_cgroup_hiearchy_root
96
+ end
97
+
98
+ new_cgroup_subsystems = [cgroups_config['subsystems']].flatten.compact
99
+ new_cgroup_subsystems ||= %w(memory cpu cpuacct blkio)
100
+ if @cgroup_subsystems and new_cgroup_subsystems != @cgroup_subsystems
101
+ @logger.warn("Cannot change cgroups subsystems after start")
102
+ else
103
+ @cgroup_subsystems = new_cgroup_subsystems
104
+ end
105
+
106
+ new_cgroups_fs_root_path = cgroups_config['fs_root']
107
+ new_cgroups_fs_root_path ||= '/sys/fs/cgroup'
108
+ new_cgroups_fs_root_path.gsub!(/\/$/, '')
109
+ if @cgroups_fs_root_path and @cgroups_fs_root_path != new_cgroups_fs_root_path
110
+ @logger.warn("Cannot change cgroups root after start")
111
+ else
112
+ @cgroups_fs_root_path = new_cgroups_fs_root_path
113
+ end
114
+ end
115
+
116
+ def applications
117
+ @applications.children
118
+ end
119
+
120
+ # Returns a +Cgroup+ object given an instance.
121
+ def cgroup_for_instance(instance)
122
+ path = [
123
+ @cgroup_hieararchy_root,
124
+ instance.worker.application.name,
125
+ instance.worker.name,
126
+ instance.index
127
+ ].join('/')
128
+ Cgroup.new(@cgroups_fs_root_path, path, @cgroup_subsystems)
129
+ end
130
+
131
+ attr_reader :logger
132
+
133
+ protected
134
+
135
+ def on_starting
136
+ @applications.start!
137
+ @socket_server.start!
138
+ runnable_state.started!
139
+ end
140
+
141
+ def on_started
142
+ @logger.info "Started"
143
+ end
144
+
145
+ def on_stopping
146
+ @applications.stop!
147
+ @logger.info "Waiting for applications to stop"
148
+ begin
149
+ timeout(10) do
150
+ loop do
151
+ break if @applications.run_state == :stopped
152
+ sleep(0.5)
153
+ end
154
+ end
155
+ rescue Timeout::Error
156
+ @logger.error "Timed out waiting for applications to stop normally, giving up"
157
+ rescue SignalException
158
+ @logger.warn "Interrupted while waiting for applications to stop"
159
+ end
160
+ @socket_server.stop!
161
+ runnable_state.stopped!
162
+ end
163
+
164
+ def on_stopped
165
+ @logger.info "Stopped"
166
+ end
167
+
168
+ def load_config_from_yaml(file_name, target = {})
169
+ File.open(file_name, 'r:utf-8') do |file|
170
+ loaded = YAML.load(file)
171
+
172
+ target.merge!(loaded.except('include'))
173
+
174
+ if (includes = loaded['include'])
175
+ includes = [includes] unless includes.is_a?(Array)
176
+ includes.flat_map { |s| Dir.glob(s) }.uniq.each do |file_name|
177
+ load_config_from_yaml(file_name, target)
178
+ end
179
+ end
180
+ end
181
+ target
182
+ rescue ConfigurationError
183
+ raise
184
+ rescue Errno::ENOENT
185
+ raise ConfigurationError, "Configuration file #{file_name} not found"
186
+ rescue => e
187
+ raise ConfigurationError, "Could not read configuration file: #{e}"
188
+ end
189
+
190
+ def expand_includes(configuration)
191
+ if configuration['include']
192
+ new_config = {}
193
+ new_config.merge!(configuration)
194
+ new_config.delete('include')
195
+ end
196
+ configuration
197
+ end
198
+
199
+ end
200
+
201
+ end