console-mux 2.0.2

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,8 @@
1
+ #!/usr/bin/env ruby
2
+ require File.expand_path(File.dirname(__FILE__)) + '/../lib/console/mux'
3
+
4
+ #require 'ruby-debug'
5
+ #Debugger.start
6
+
7
+ Console::Mux.run_argv
8
+
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env ruby
2
+ #
3
+ # Copyright (C) 2012 Common Ground Publishing
4
+ #
5
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ # this software and associated documentation files (the "Software"), to deal in
7
+ # the Software without restriction, including without limitation the rights to
8
+ # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ # the Software, and to permit persons to whom the Software is furnished to do so,
10
+ # subject to the following conditions:
11
+ #
12
+ # The above copyright notice and this permission notice shall be included in all
13
+ # copies or substantial portions of the Software.
14
+ #
15
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21
+ #
22
+
23
+ require 'optparse'
24
+
25
+ require 'console/mux/console'
26
+
27
+ module Console
28
+ module Mux
29
+ include Log4r
30
+
31
+ BUNDLE_EXEC_SH = File.expand_path('bundle_exec.sh',
32
+ File.join(__FILE__, '..', 'mux'))
33
+
34
+ # @param [Array] args commandline arguments
35
+ def self.run_argv(args = ARGV)
36
+ options = {
37
+ :init_file => nil
38
+ }
39
+
40
+ OptionParser.new do |opts|
41
+ opts.banner = "Usage: console-mux <init_file>"
42
+ opts.on_tail('-h', '--help', 'Show this message') do
43
+ puts opts
44
+ exit
45
+ end
46
+ opts.on('-f', '--init=FILE', 'Load this init file') do |file|
47
+ options[:init_file] = file
48
+ end
49
+ end.parse!(args)
50
+
51
+ begin
52
+ run(options)
53
+ rescue Errno::ENOENT => e
54
+ $stderr.puts e.message
55
+ $stderr.puts e.backtrace.join("\n ")
56
+ exit 1
57
+ end
58
+ end
59
+
60
+ def self.run(options)
61
+ Console.new(options)
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,55 @@
1
+ #
2
+ # Copyright (C) 2012 Common Ground Publishing
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ # this software and associated documentation files (the "Software"), to deal in
6
+ # the Software without restriction, including without limitation the rights to
7
+ # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ # the Software, and to permit persons to whom the Software is furnished to do so,
9
+ # subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in all
12
+ # copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18
+ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ require "log4r/outputter/outputter"
22
+ require "log4r/staticlogger"
23
+
24
+ require 'console/mux/rolling_array'
25
+
26
+ module Console
27
+
28
+ module Mux
29
+
30
+ # A Log4r outputtter that captures a rolling buffer of logged
31
+ # messages.
32
+ class BufferOutputter < Log4r::Outputter
33
+
34
+ def initialize(_name, size, hash={})
35
+ @buffer = RollingArray.new(size)
36
+ super(_name, hash)
37
+ end
38
+
39
+ # Iterate through each captured log line.
40
+ def each(*args, &block)
41
+ @buffer.each(*args, &block)
42
+ end
43
+
44
+ private
45
+
46
+ # write to buffer
47
+ def write(data)
48
+ @buffer << data
49
+ end
50
+
51
+ end
52
+
53
+ end
54
+
55
+ end
@@ -0,0 +1,35 @@
1
+ #!/bin/sh
2
+ #
3
+ # bundle_exec.sh - emulate 'bundle exec' but with a shell script
4
+ # rather than a ruby script. Modifies the environment just like
5
+ # bundle exec would.
6
+ #
7
+ # Running "bundle exec ruby" starts a ruby in which then execs the
8
+ # command, 'ruby'. In JRuby 1.6, the command is started in a child
9
+ # process. When using a Foreman-like tool that has the ability to
10
+ # kill and/or restart such a command, only the parent will be killed
11
+ # while the child ruby continues on. This bundle_exec.sh wokrs
12
+ # similarly to bundle exec, but the exec is done in the shell rather
13
+ # than in JRuby to avoid the difficult-to-kill child process.
14
+ #
15
+ # For bourne-compatible shells.
16
+ #
17
+ # Author: Patrick Mahoney <pat@polycrystal.org>
18
+
19
+ # Run 'bundle exec' in a subshell; echo a string that when eval'ed
20
+ # will duplicate the 'bundle exec' environment.
21
+ env=$(bundle exec sh -c \
22
+ 'echo "export BUNDLE_BIN_PATH=\"$BUNDLE_BIN_PATH\"; \
23
+ export PATH=\"$PATH\"; \
24
+ export BUNDLE_GEMFILE=\"$BUNDLE_GEMFILE\"; \
25
+ export RUBYOPT=\"$RUBYOPT\"";')
26
+
27
+ ret="$?"
28
+ if [ "$ret" = 0 ]; then
29
+ # Bring bundle exec environment into the current shell.
30
+ eval $env
31
+ exec "$@"
32
+ else
33
+ echo "$env"
34
+ exit "$ret"
35
+ fi
@@ -0,0 +1,110 @@
1
+ #
2
+ # Copyright (C) 2012 Common Ground Publishing
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ # this software and associated documentation files (the "Software"), to deal in
6
+ # the Software without restriction, including without limitation the rights to
7
+ # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ # the Software, and to permit persons to whom the Software is furnished to do so,
9
+ # subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in all
12
+ # copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18
+ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ require 'log4r'
22
+
23
+ class String
24
+ # DJB hash algorithm 2. The default Ruby string hash is not stable
25
+ # across Ruby invocations (for security reasons).
26
+ def hash_djb2
27
+ hash = 5381
28
+ each_codepoint do |c|
29
+ hash = ((hash << 5) + hash) + c # equiv. hash * 33 + c?
30
+ end
31
+ hash
32
+ end
33
+ end
34
+
35
+ module Console
36
+
37
+ module Mux
38
+
39
+ class ColorFormatter < Log4r::BasicFormatter
40
+ BASIC_COLORS = {
41
+ :black => 0,
42
+ :red => 1,
43
+ :green => 2,
44
+ :yellow => 3,
45
+ :blue => 4,
46
+ :magenta => 5,
47
+ :cyan => 6,
48
+ :grey => 7,
49
+ }
50
+
51
+ # On 256 color term, many colors beyond 127 seem to be
52
+ # unreadably light. Of course, if your terminal has a dark
53
+ # background that may be preferable.
54
+ N_COLORS = [127, `tput colors`.to_i].min
55
+
56
+ COLOR_STRINGS = []
57
+ N_COLORS.times do |i|
58
+ COLOR_STRINGS[i] = `tput setaf #{i}`
59
+ end
60
+
61
+ RESET_COLORS = `tput sgr0` # last string is reset
62
+
63
+ MAX_WIDTH = 35
64
+ MIN_WIDTH = 8
65
+
66
+ attr_accessor :label_width
67
+
68
+ def initialize(hash={})
69
+ super(hash)
70
+ @label_width = MIN_WIDTH
71
+ @colors = Hash.new
72
+ end
73
+
74
+ def format(event)
75
+ self.label_width = [[self.label_width, event.name.size].max, MAX_WIDTH].min
76
+
77
+ color, name = case event.name
78
+ when 'process'
79
+ [:red, '-' * label_width]
80
+ else
81
+ [color_for(event.name), event.name]
82
+ end
83
+
84
+ "%s%-#{label_width}s|%s %s" %
85
+ [set_color_str(color), name, unset_color_str, event.data]
86
+ end
87
+
88
+ protected
89
+
90
+ def color_for(name)
91
+ @colors[name] ||= name.hash_djb2 % N_COLORS
92
+ end
93
+
94
+ def set_color_str(color)
95
+ c = if color.kind_of? Symbol
96
+ BASIC_COLORS[color]
97
+ else
98
+ color
99
+ end
100
+ COLOR_STRINGS[c]
101
+ end
102
+
103
+ def unset_color_str
104
+ RESET_COLORS
105
+ end
106
+ end
107
+
108
+ end
109
+
110
+ end
@@ -0,0 +1,134 @@
1
+ #
2
+ # Copyright (C) 2012 Common Ground Publishing
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ # this software and associated documentation files (the "Software"), to deal in
6
+ # the Software without restriction, including without limitation the rights to
7
+ # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ # the Software, and to permit persons to whom the Software is furnished to do so,
9
+ # subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in all
12
+ # copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18
+ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ require 'console/mux/util'
22
+ require 'console/mux/process'
23
+ require 'console/mux/run_with'
24
+ require 'pathname'
25
+
26
+ module Console
27
+ module Mux
28
+ class Command
29
+ include Util
30
+
31
+ class << self
32
+ def option_reader(*syms)
33
+ syms.each do |sym|
34
+ define_method(sym) do
35
+ self[sym]
36
+ end
37
+ end
38
+ end
39
+
40
+ def option_writer(*syms)
41
+ syms.each do |sym|
42
+ define_method("#{sym}=") do |val|
43
+ self[sym] = val
44
+ end
45
+ end
46
+ end
47
+
48
+ def option_accessor(*syms)
49
+ option_reader(*syms)
50
+ option_writer(*syms)
51
+ end
52
+ end
53
+
54
+ attr_reader :opts, :commandline
55
+
56
+ option_accessor :name, :command, :env, :run_with, :chdir
57
+
58
+ # @param opts execution options arbitrary keys and values, but some are special
59
+ #
60
+ # @option opts [Boolean] :noop if true, the command will not be
61
+ # run (debugging convenience)
62
+ #
63
+ # @option opts [String] :command the command to run, e.g. +ls -l+
64
+ #
65
+ # @option opts [String] :name the name of the command; if
66
+ # missing, a name will be auto-generated from the command
67
+ #
68
+ # @option opts [String] :chdir change to this dir before running the command
69
+ #
70
+ # @option opts [String] :run_with An array of filters. The filters are
71
+ # applied to the command in reverse order. A string filter is
72
+ # simply prepended to the command. A symbol is called as a method
73
+ # on RunWith that accepts `(command, opts)` args and returns
74
+ # `[new_command, new_opts]`.
75
+ def initialize(opts)
76
+ @opts = opts.dup
77
+
78
+ self.env ||= {}
79
+ self.run_with ||= []
80
+
81
+ # name need not be unique here. When added to a CommandSet,
82
+ # within that set it may be assigned a unique name based off
83
+ # this name.
84
+ self.name ||= auto_name(command)
85
+
86
+ @commandline = expand
87
+ end
88
+
89
+ def [](key)
90
+ value = opts[key]
91
+ if value.respond_to? :call
92
+ value.call(self)
93
+ else
94
+ value
95
+ end
96
+ end
97
+
98
+ # Set an option on this command. The +value+ may be a Proc
99
+ # object taking a single argument (or anything that responds to
100
+ # +:call+), in which case the value on get (+#[]) will be the
101
+ # result of that Proc called with +self+.
102
+ def []=(key, value)
103
+ opts[key] = value
104
+ end
105
+
106
+ def dir
107
+ dir = self[:chdir] || '.'
108
+ if self[:base_dir] && !Pathname.new(dir).absolute?
109
+ File.join(self[:base_dir], dir)
110
+ else
111
+ dir
112
+ end
113
+ end
114
+
115
+ def to_s
116
+ commandline.sub(/^#{::Console::Mux::BUNDLE_EXEC_SH}/, '*bundle_exec.sh')
117
+ end
118
+
119
+ private
120
+
121
+ # Apply any +:run_with+ options to generate the expanded
122
+ # commandline that should be executed.
123
+ def expand
124
+ run_with.reverse.reduce(command) do |commandline, filter|
125
+ if RunWith.respond_to?(filter)
126
+ RunWith.send(filter, self, commandline)
127
+ else
128
+ "#{filter} #{commandline}"
129
+ end
130
+ end
131
+ end
132
+ end
133
+ end
134
+ end
@@ -0,0 +1,235 @@
1
+ #
2
+ # Copyright (C) 2012 Common Ground Publishing
3
+ #
4
+ # Permission is hereby granted, free of charge, to any person obtaining a copy of
5
+ # this software and associated documentation files (the "Software"), to deal in
6
+ # the Software without restriction, including without limitation the rights to
7
+ # use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
8
+ # the Software, and to permit persons to whom the Software is furnished to do so,
9
+ # subject to the following conditions:
10
+ #
11
+ # The above copyright notice and this permission notice shall be included in all
12
+ # copies or substantial portions of the Software.
13
+ #
14
+ # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
15
+ # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
16
+ # FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
17
+ # COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
18
+ # IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
19
+ # CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
20
+ #
21
+ require 'console/mux/events'
22
+
23
+ module Console
24
+ module Mux
25
+ class CommandSet
26
+ include Console::Mux::Events
27
+
28
+ class << self
29
+ # Yield to the block only after all processes are stopped.
30
+ # Does not check that all processes are actually running; just
31
+ # waits for an +:exit+ event from each.
32
+ #
33
+ # @param [Array<Process>] processes
34
+ def join(processes)
35
+ pending_exit = processes.size
36
+ processes.each do |proc|
37
+ proc.on(:exit) do
38
+ pending_exit -= 1
39
+ if pending_exit == 0 && block_given?
40
+ yield
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
46
+
47
+ attr_reader :max_width
48
+
49
+ def initialize
50
+ @max_width = 0
51
+ @commands = Array.new
52
+ @commands_by_name = Hash.new
53
+
54
+ @processes = Hash.new
55
+ @stopped_at = Hash.new
56
+ end
57
+
58
+ # Add a command (see #add) and start it (see #start).
59
+ def add_and_start(command)
60
+ name = add(command)
61
+ start(name)
62
+ end
63
+
64
+ # Add a command to this CommandSet.
65
+ #
66
+ # @param [Command] command
67
+ #
68
+ # @return [String] the command name, which may be different from
69
+ # +command.name+ if required to be unique within this CommandSet
70
+ def add(command)
71
+ name = unique_name(command.name)
72
+
73
+ @commands << command
74
+ @commands_by_name[name] = command
75
+ @max_width = [@max_width, name.size].max
76
+
77
+ name
78
+ end
79
+
80
+ def remove(name)
81
+ return unless @commands_by_name[name]
82
+
83
+ stop(name) rescue nil
84
+ @stopped_at.delete(name)
85
+ @commands.delete(@commands_by_name.delete(name))
86
+ end
87
+
88
+ # TODO: make this work?
89
+ # def merge(command)
90
+ # if i = @commands.first_index(command)
91
+ # @commands[i] = command
92
+ # @commands_by_name[command.name] = command
93
+ # else
94
+ # add(command)
95
+ # end
96
+ # end
97
+
98
+ def include?(command)
99
+ @commands.include?(command)
100
+ end
101
+
102
+ def stopped?
103
+ @processes.empty?
104
+ end
105
+
106
+ def each(&block)
107
+ @commands.each(&block)
108
+ end
109
+
110
+ def [](name)
111
+ @commands_by_name[name]
112
+ end
113
+
114
+ def start(name)
115
+ if proc = @processes[name]
116
+ raise "already have process for #{name}: #{proc.pid}"
117
+ end
118
+
119
+ @stopped_at.delete(name)
120
+ proc = Process.start(@commands_by_name[name], name)
121
+ proc.on(:exit) do
122
+ @processes.delete(name)
123
+ @stopped_at[name] = Time.now
124
+ fire(:stopped) if @processes.empty?
125
+ end
126
+ @processes[name] = proc
127
+ proc
128
+ end
129
+
130
+ def stop(name)
131
+ if proc = @processes.delete(name)
132
+ proc.stop
133
+ else
134
+ raise "no process for #{name}"
135
+ end
136
+ end
137
+
138
+ def stop_all
139
+ @processes.each do |name, proc|
140
+ proc.stop
141
+ end
142
+ end
143
+
144
+ # When all currently-running processes stop, yield to the block
145
+ def join(&block)
146
+ self.class.join(@processes.values, &block)
147
+ end
148
+
149
+ # Stop and then start the process +name+.
150
+ def restart(name)
151
+ if proc = @processes[name]
152
+ proc.on(:exit) do
153
+ start(name)
154
+ end
155
+ proc.stop
156
+ elsif @commands_by_name[name]
157
+ start(name)
158
+ else
159
+ raise "no process for #{name}"
160
+ end
161
+ end
162
+
163
+ def pretty_seconds(secs)
164
+ if secs < 60
165
+ "#{secs} s"
166
+ elsif secs < (60 * 60)
167
+ "#{secs/60} min"
168
+ elsif secs < (60 * 60 * 24)
169
+ "#{secs/60/60} hr"
170
+ else
171
+ "%0.1f days" % (secs/60.0/60.0/24.0)
172
+ end
173
+ end
174
+
175
+ def pretty_bytes(bytes)
176
+ if bytes < 1024
177
+ "#{bytes}B"
178
+ elsif bytes < 1024 * 1024
179
+ "#{bytes/1024} kiB"
180
+ elsif bytes < 1024* 1024 * 1024
181
+ "#{bytes/1024/1024} MiB"
182
+ else
183
+ "%0.2d GiB" % (bytes.to_f/(1024**3))
184
+ end
185
+ end
186
+
187
+ def status
188
+ fmt = "%-#{max_width}s %8s %12s %8s %10s\n"
189
+
190
+ rows = []
191
+ rows << fmt % ['NAME', 'PID', 'RSS', 'TIME', 'CPUTIME']
192
+ @commands_by_name.each do |name, c|
193
+ pid, rss, time, cputime =
194
+ if proc = @processes[name]
195
+ [proc.pid.to_s,
196
+ pretty_bytes(proc.rss),
197
+ proc.etime,
198
+ proc.cputime]
199
+ elsif @stopped_at[name]
200
+ downtime = Time.now.to_i - @stopped_at[name].to_i
201
+ ['-', " down #{pretty_seconds(downtime)}", nil, nil]
202
+ else
203
+ ['-', " (pending)", nil, nil]
204
+ end
205
+
206
+ rows << (fmt % [name, pid, rss, time, cputime])
207
+ end
208
+
209
+ rows
210
+ end
211
+
212
+ def count
213
+ "#{@commands.size} commands with #{@processes.size} running"
214
+ end
215
+
216
+ private
217
+
218
+ # Generate a unique command name based on `name`. If there is
219
+ # already a command `name`, a digit will be appended, starting
220
+ # at 2, and increasing until an unused name is found.
221
+ def unique_name(name)
222
+ if @commands_by_name.include?(name)
223
+ count = 2
224
+ name2 = nil
225
+ while (name2 = name + count.to_s) && @commands_by_name.include?(name2)
226
+ count += 1
227
+ end
228
+ name2
229
+ else
230
+ name
231
+ end
232
+ end
233
+ end
234
+ end
235
+ end