tmux-connector 0.0.2

Sign up to get free protection for your applications and to get access to all the features.
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ *.gem
2
+ *.rbc
3
+ .bundle
4
+ .config
5
+ .yardoc
6
+ Gemfile.lock
7
+ InstalledFiles
8
+ _yardoc
9
+ coverage
10
+ doc/
11
+ lib/bundler/man
12
+ pkg
13
+ rdoc
14
+ spec/reports
15
+ test/tmp
16
+ test/version_tmp
17
+ tmp
data/Gemfile ADDED
@@ -0,0 +1,3 @@
1
+ source 'https://rubygems.org'
2
+
3
+ gemspec
data/LICENSE.txt ADDED
@@ -0,0 +1,22 @@
1
+ Copyright (c) 2013 Ivan Kusalic
2
+
3
+ MIT License
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining
6
+ a copy of this software and associated documentation files (the
7
+ "Software"), to deal in the Software without restriction, including
8
+ without limitation the rights to use, copy, modify, merge, publish,
9
+ distribute, sublicense, and/or sell copies of the Software, and to
10
+ permit persons to whom the Software is furnished to do so, subject to
11
+ the following conditions:
12
+
13
+ The above copyright notice and this permission notice shall be
14
+ included in all copies or substantial portions of the Software.
15
+
16
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
17
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
18
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
19
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
20
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
21
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
22
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,352 @@
1
+ # TmuxConnector
2
+
3
+ Manage multiple servers using SSH and [tmux].
4
+
5
+
6
+ ## Features:
7
+ * work on multiple sessions in parallel
8
+ * sessions can be persisted (actually recreated) after computer restarts
9
+ - they are lost only if you delete them explicitly
10
+ * complex layouts customizable for different server groups
11
+ * issuing commands to all servers or just a selected subgroups
12
+
13
+ ## Quick tease
14
+
15
+ `tcon start staging_config.yml -n staging -p 'all staging servers'`
16
+
17
+ - crate a session (name: 'staging', description: 'all staging servers') with
18
+ complex layout structure (multiple windows with (potentially) different pane
19
+ layouts)
20
+ - connect to all servers
21
+ - attach to tmux session
22
+
23
+ `tcon send staging 'sudo su'`
24
+
25
+ - send to all servers (in 'staging' session) `sudo su` command
26
+
27
+ `tcon send staging 'top' -g 'lbs'`
28
+
29
+ - send `top` command to all loadbalancing nodes in 'staging' session
30
+
31
+ `tcon send production 'tail -f' -f 'rdb'`
32
+
33
+ - send `tail -f` command to all database nodes in 'production' session
34
+
35
+ `tcon resume s#3`
36
+
37
+ - resume (recreate) 's#3' session, even after computer restart
38
+
39
+
40
+ ## CLI description
41
+
42
+ [CLI] uses [docopt] to parse command line options.
43
+
44
+ ~~~
45
+ tcon enables establishing connections (ssh) to multiple servers and executing
46
+ commands on those servers. The sessions can be persisted (actually recreated)
47
+ even after computer restarts. Complex sessions with different layouts for
48
+ different kinds of servers can be easily created.
49
+
50
+ Usage:
51
+ tcon start <config-file> [--ssh-config=<file>]
52
+ [--session-name=<name>] [--purpose=<description>]
53
+ tcon resume <session-name>
54
+ tcon delete (<session-name> | --all)
55
+ tcon list
56
+ tcon send <session-name> (<command> | --command-file=<file>)
57
+ [--server-filter=<regex>] [--group-filter=<regex>] [--verbose]
58
+ tcon --help
59
+ tcon --version
60
+
61
+ Options:
62
+ <config-file> Path to configuration file. Configuration file
63
+ describes how new session is started. YAML format.
64
+ <session-name> Name that identifies the session. Must be unique.
65
+ <command> Command to be executed on remote server[s].
66
+ -s --ssh-config=file Path to ssh config file [default: ~/.ssh/config].
67
+ -n --session-name=name Name of the session to be used in the tcon command.
68
+ -p --purpose=description Description of session's purpose.
69
+ --all Delete all existing sessions.
70
+ -f --server-filter=regex Filter to select a subset of the servers.
71
+ Should be valid ruby regex.
72
+ -g --group-filter=regex Filter to select a subset of the servers via
73
+ group membership. Should be valid ruby regex.
74
+ -c --command-file=file File containing the list of commands to be
75
+ executed on remote server[s].
76
+ -v --verbose Report how many servers were affected by the send
77
+ command.
78
+ -h --help Show this screen.
79
+ --version Show version.
80
+ ~~~
81
+
82
+
83
+ ## Configuration
84
+
85
+ To use this gem, you need to create a configuration file. This shouldn't be
86
+ that hard and here I provide exhaustive details about configuration files.
87
+
88
+ (If there is enough interest, in future versions there could be a special
89
+ command to simplify generation of configuration files. To accelerate the
90
+ process, open an issue or drop me an email: << username >>@gmail.com)
91
+
92
+ Let's get to it.
93
+
94
+ The configuration file is in [YAML] format.
95
+
96
+ Let's say the following ssh config file that will be used:
97
+ ~~~
98
+ KeepAlive yes
99
+ ServerAliveInterval 2
100
+ StrictHostKeyChecking no
101
+ UserKnownHostsFile=/dev/null
102
+
103
+ Host staging.cache-staging-1
104
+ Hostname ec2-111-42-111-42.eu-west-1.compute.amazonaws.com
105
+ Port 4242
106
+ IdentityFile /Users/ikusalic/.ssh/some-pem-file.pem
107
+ User ubuntu
108
+
109
+ Host dev.database-staging-1
110
+ << omitted >>
111
+
112
+ Host dev.database-staging-3
113
+ << omitted >>
114
+
115
+ Host dev.mongodb-single-replica-1
116
+ << omitted >>
117
+
118
+ Host dev.haproxy-staging-72
119
+ << omitted >>
120
+
121
+ Host dev.haproxy-staging-73
122
+ << omitted >>
123
+
124
+ Host dev.nginx-staging-11
125
+ << omitted >>
126
+
127
+ Host dev.nginx-staging-15
128
+ << omitted >>
129
+
130
+ Host dev.node-staging-127
131
+ << omitted >>
132
+
133
+ Host dev.node-staging-129
134
+ << omitted >>
135
+
136
+ Host dev.node-staging-130
137
+ << omitted >>
138
+
139
+ Host dev.node-staging-135
140
+ << omitted >>
141
+
142
+ << ... >>
143
+ ~~~
144
+
145
+ Here's a 'real world' configuration file that shows of all the available
146
+ options and could be use with previous ssh config file:
147
+
148
+ ~~~yaml
149
+ regex: !ruby-regexp '^(\w+)\.(\w+)-([\w-]+)-(\d+)$'
150
+ reject-regex: !ruby-regexp '-(nodes|to_ignore)-'
151
+ regex-parts-to:
152
+ group-by: [1]
153
+ sort-by: [3]
154
+ name:
155
+ regex-ignore-parts: [0, 2]
156
+ separator: '-'
157
+ prefix: 'dev--'
158
+ merge-groups:
159
+ misc: ['cache', 'db', 'mongodb']
160
+ lbs: ['haproxy', 'nginx']
161
+ layout:
162
+ default:
163
+ custom:
164
+ max-horizontal: 3
165
+ max-vertical: 3
166
+ panes-flow: vertical
167
+ group-layouts:
168
+ misc:
169
+ tmux:
170
+ layout: 'tiled'
171
+ max-panes: 6
172
+ node:
173
+ tmux:
174
+ layout: 'tiled'
175
+ ~~~
176
+
177
+ * * *
178
+ __'regex'__ field is the most important field. Some other field reference
179
+ this one. It provides a rule on how to parse host names from ssh config file.
180
+ The regex should be a valid ruby regex. (If you're not familiar with ruby
181
+ regexes, consider visiting [rubulator] and playing around.)
182
+
183
+ All host whose host names fail the regex will be ignored.
184
+
185
+ For example if ssh config file include the following host definition:
186
+ ~~~
187
+ Host dev.database-staging-1
188
+ ~~~
189
+
190
+ and the following regex is used:
191
+ ~~~
192
+ regex: !ruby-regexp '^(\w+)\.(\w+)-([\w-]+)-(\d+)$'
193
+ ~~~
194
+
195
+ the name 'dev.database-staging-1' will be broken to 4 groups:
196
+ ~~~
197
+ 'dev', 'database', 'staging', 1
198
+ ~~~
199
+
200
+ This regex is used for all the host names and should be designed accordingly.
201
+
202
+ The idea behind the regex is to enable sorting and grouping of hosts from regex
203
+ groups extracted from host names. Those groups are used to crate meaningful
204
+ layouts. I know, sounds more complex than it really is...
205
+
206
+ * * *
207
+ __'reject-regex'__ (optional) field is used to ignore some hosts while starting
208
+ a session.
209
+
210
+ * * *
211
+ Fields (__'regex-parts-to'__) __'group-by'__ and __'sort-by'__ are referencing
212
+ before mentioned __'regex'__ field. As their names suggest, they decide which
213
+ servers constitute a group (and share layout and potentially commands) and how
214
+ to sort
215
+ serves in a group. Both fields can reference more than one regex group.
216
+
217
+ In the above example, for 'dev.database-staging-1' host name, a group to which
218
+ the host belongs would be 2nd group, which is: 'database'.
219
+
220
+ * * *
221
+ (optional) field __'name'__ and it's (optional) subfields
222
+ __'regex-ignore-parts'__, __'separator'__ and __'prefix'__ decide how to name
223
+ the servers. If those fields are omitted, ssh host name is used instead.
224
+
225
+ Filed __'regex-ignore-parts'__ potentially removes some regex groups from name,
226
+ __'separator'__ is used to separate left-over groups and it's possible to
227
+ specify __'prefix'__ for the name.
228
+
229
+ * * *
230
+ (optional) field __'merge-groups'__ contains groups that should be merged (for
231
+ layout purposes) together. This can be used to group a few servers that are
232
+ unique in type or small in numbers. E.g. grouping different DB servers.
233
+
234
+ ~~~
235
+ lbs: ['haproxy', 'nginx']
236
+ ~~~
237
+ In this example two different kinds of loadbalancers are grouped together.
238
+
239
+ Note that the servers from merge groups can later be referenced with both
240
+ original and merge-group name.
241
+
242
+ * * *
243
+ Finally, what's left is the layout definition:
244
+
245
+ There are 2 main ways to specify a layout for a (merge-)group:
246
+
247
+ 1. built-in tmux layouts (even-horizontal, even-vertical, main-horizontal,
248
+ main-vertical or tiled)
249
+ - defines a tmux layout and (optionally) maximum number of panes in one
250
+ window (default 9).
251
+ 2. custom tiled layout
252
+ - defines filed layout with maximal size of rows and columns. There is
253
+ also an (optional) option to specify if the panes flow from left to
254
+ right (horizontal - default) or from top to bottom (vertical)
255
+
256
+ The layouts are applied individually to any merge group and to any normal
257
+ (regex) group not belonging to some merge group. If there are more servers in
258
+ a group then layout allows on a single window, next window for that group is
259
+ added. Servers from different groups never share a window.
260
+
261
+
262
+ ## Requirements
263
+ To be able to use the gem you should have ruby 1.9+ and tmux installed on a *nix
264
+ (Mac OS X, Linux, ...) machine. (Windows: here be dragons)
265
+
266
+ Interaction with tmux is done via bash commands.
267
+
268
+ Minimal familiarity with tmux is required.
269
+ For a start, switching between panes/windows and attaching/detaching is enough:
270
+
271
+ * detach session: `<prefix>d`
272
+ * attach session: `tmux attach -t <session-name>`
273
+ * navigate windows (next/previous): `<prefix>n` & `<prefix>p`
274
+ * navigate panes: `<prefix><arrow>`
275
+
276
+ (prefix is by default `C-b`)
277
+
278
+
279
+ ## Installation
280
+
281
+ The gem provides CLI and currently it is not intended to be used as part of
282
+ bigger ruby apps.
283
+
284
+ Install it with:
285
+ ~~~
286
+ $ gem install tmux-connector
287
+ ~~~
288
+
289
+
290
+ #### Installing tmux
291
+
292
+ If tmux isn't already installed, install it using your favorite mathod,
293
+ e.g.:
294
+ * Linux: `apt-get install tmux`
295
+ * Mac OS X: `brew install tmux`
296
+
297
+
298
+ ## Tips
299
+
300
+ ### SSH config files
301
+
302
+ If you plan on specifying separate ssh config file when starting session,
303
+ consider adding the following lines on top:
304
+ ~~~
305
+ StrictHostKeyChecking no
306
+ UserKnownHostsFile=/dev/null
307
+ ~~~
308
+
309
+ That way you won't have problems with known hosts changes or with infinite
310
+ questions to approve new hosts. Do this _only_ if you understand security
311
+ consequences and are sure that it is safe to do so.
312
+
313
+
314
+ ### Tmux configuration
315
+
316
+ Since gem uses tmux, consider configuring it for your purposes. E.g. I'm a [Vim]
317
+ user, and so configure tmux to use Vim-like bindings to switch panes. For more
318
+ information, check my [dotfiles].
319
+
320
+
321
+ ## Contributing
322
+
323
+ 1. Fork it
324
+ 2. Create your feature branch (`git checkout -b my-new-feature`)
325
+ 3. Commit your changes (`git commit -am 'Add some feature'`)
326
+ 4. Push to the branch (`git push origin my-new-feature`)
327
+ 5. Create new Pull Request
328
+
329
+ Or just mail me, mail: << username >>@gmail.com
330
+
331
+ This is my first real gem, so all your comments are more than welcome.
332
+ I'd really appreciate ruby code improvements/refactoring comments or usability
333
+ comments (all other are welcome too). Just _drop me a line_. :)
334
+
335
+
336
+ ## Comments, ideas or if you feel like chatting
337
+
338
+ Take a look at `TODO.md` file (in the repository) for ideas about additional
339
+ features in new versions.
340
+
341
+ << username >>@gmail.com
342
+
343
+ I'd be happy to hear from you.
344
+
345
+
346
+ [docopt]: https://github.com/docopt/docopt
347
+ [tmux]: http://en.wikipedia.org/wiki/Tmux
348
+ [CLI]: http://en.wikipedia.org/wiki/Command-line_interface
349
+ [Vim]: http://www.vim.org/
350
+ [dotfiles]: https://github.com/ikusalic/dotfiles
351
+ [YAML]: http://en.wikipedia.org/wiki/YAML
352
+ [rubulator]: http://rubular.com/
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
data/TODO.md ADDED
@@ -0,0 +1,12 @@
1
+ possible features for new version:
2
+ * ensure it works with zsh
3
+ * generate default config via just regex(es)
4
+ * add strict config file validation
5
+ * add window (via option) that won't connect to nay server
6
+ - for tcon commands and regular actions on local machine
7
+ * startup command
8
+
9
+ code related:
10
+ * add specs
11
+ * test with ruby 2.0
12
+ * refactoring
data/bin/tcon ADDED
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require_relative '../lib/tmux-connector'
4
+
5
+ TmuxConnector.main ARGV
@@ -0,0 +1,27 @@
1
+ require_relative 'commands/delete'
2
+ require_relative 'commands/list'
3
+ require_relative 'commands/resume'
4
+ require_relative 'commands/send'
5
+ require_relative 'commands/start'
6
+
7
+
8
+ module TmuxConnector
9
+ COMMANDS = %w[ start resume delete list send ]
10
+
11
+ def self.process_command(args)
12
+ command = detect_command args
13
+ klass = get_class command
14
+ command_obj = klass.new args
15
+ command_obj.run
16
+ end
17
+
18
+ def self.detect_command(args)
19
+ COMMANDS.each { |e| return e if args[e] }
20
+ raise 'unkonwn command'
21
+ end
22
+
23
+ def self.get_class(command)
24
+ class_name = command.split('-').map { |e| e.capitalize }.join
25
+ return TmuxConnector.const_get class_name
26
+ end
27
+ end
@@ -0,0 +1,24 @@
1
+ require_relative '../persistence_handler'
2
+
3
+
4
+ module TmuxConnector
5
+ class Delete
6
+ attr_reader :delete_all
7
+ attr_reader :name
8
+
9
+ def initialize(args)
10
+ @name = args['<session-name>']
11
+ @delete_all = args['--all']
12
+ end
13
+
14
+ def run()
15
+ if name
16
+ TmuxConnector.delete_session name
17
+ TmuxConnector.delete_tmux_session name
18
+ elsif delete_all
19
+ TmuxConnector.delete_all
20
+ TmuxConnector.delete_all_tmux_sessions
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,16 @@
1
+ require_relative '../persistence_handler'
2
+
3
+
4
+ module TmuxConnector
5
+ class List
6
+ def initialize(args)
7
+ end
8
+
9
+ def run()
10
+ sessions_data = TmuxConnector.list_sessions
11
+ puts "sessions:"
12
+ puts sessions_data.to_yaml
13
+ puts "-" * 20
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,17 @@
1
+ require_relative '../session'
2
+
3
+
4
+ module TmuxConnector
5
+ class Resume
6
+ attr_reader :name
7
+ attr_reader :session
8
+
9
+ def initialize(args)
10
+ @session = Session.load_by_name args['<session-name>']
11
+ end
12
+
13
+ def run()
14
+ session.start
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,42 @@
1
+ require_relative '../session'
2
+ require_relative '../tmux_handler'
3
+
4
+
5
+ module TmuxConnector
6
+ class Send
7
+ attr_reader :commands
8
+ attr_reader :group_filter
9
+ attr_reader :name
10
+ attr_reader :server_filter
11
+ attr_reader :session
12
+ attr_reader :verbose
13
+
14
+ def initialize(args)
15
+ @name = args['<session-name>']
16
+ @verbose = args['--verbose']
17
+
18
+ load_commands args
19
+
20
+ @server_filter = Regexp.new(args['--server-filter']) rescue nil
21
+ @group_filter = Regexp.new(args['--group-filter']) rescue nil
22
+
23
+ @session = Session.load_by_name args['<session-name>']
24
+ end
25
+
26
+ def run()
27
+ session.tmux_session.send_commands(commands, server_filter, group_filter, verbose)
28
+ end
29
+
30
+ private
31
+
32
+ def load_commands(args)
33
+ if args['<command>']
34
+ @commands = args['<command>']
35
+ else
36
+ file = File.expand_path args['--command-file']
37
+ raise "command file (#{ file }) not found" unless File.exist? file
38
+ @commands = open(file) { |f| f.read }
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,65 @@
1
+ require_relative '../config_handler'
2
+ require_relative '../host'
3
+ require_relative '../session'
4
+ require_relative '../ssh_config_parser'
5
+
6
+
7
+ module TmuxConnector
8
+ class Start
9
+ attr_reader :config
10
+ attr_reader :hosts
11
+ attr_reader :groups
12
+ attr_reader :merge_rules
13
+ attr_reader :session
14
+
15
+ def initialize(args)
16
+ @config = TmuxConnector.get_config args['<config-file>']
17
+
18
+ ssh_hostnames = SSHConfig.get_hosts(args['--ssh-config'], config['reject-regex'])
19
+ @hosts = ssh_hostnames.reduce([]) do |acc, name|
20
+ ( acc << Host.new(name, config) ) rescue nil
21
+ acc
22
+ end
23
+
24
+ generate_groups
25
+ generate_merge_rules
26
+
27
+ @session = Session.new config, args, groups, merge_rules
28
+ end
29
+
30
+ def run()
31
+ session.save
32
+ session.start
33
+ end
34
+
35
+ private
36
+
37
+ def generate_groups()
38
+ @groups = hosts.reduce({}) do |acc, e|
39
+ acc[e.group_id] ||= []
40
+ acc[e.group_id] << e
41
+ acc
42
+ end
43
+ sort_groups!
44
+ end
45
+
46
+ def generate_merge_rules()
47
+ @merge_rules = {}
48
+ config['merge-groups'].each do |name, elements|
49
+ elements.each { |e| @merge_rules[e] = name }
50
+ end
51
+ @groups.keys.each { |e| @merge_rules[e] ||= e }
52
+ end
53
+
54
+ def sort_groups!()
55
+ @groups.each do |k, v|
56
+ numbers_only = v.all? { |e| e.sort_value =~ /^[-+]?[0-9]+$/ }
57
+ if numbers_only
58
+ v.sort! { |a, b| a.sort_value.to_i <=> b.sort_value.to_i }
59
+ else
60
+ v.sort_by!(&:sort_value)
61
+ end
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,47 @@
1
+ require 'yaml'
2
+
3
+
4
+ module TmuxConnector
5
+ DEFAULT_CONFIG_FILE = 'lib/tmux-connector/default_config.yml'
6
+
7
+ def self.get_config(config_file)
8
+ config = read_config config_file
9
+ process_config! config
10
+ validate_config config
11
+ return config
12
+ end
13
+
14
+ def self.read_config(config_file)
15
+ full_path = File.expand_path config_file
16
+ raise "configuration file (#{config_file}) not found" unless File.exist? full_path
17
+ config = YAML.load_file full_path
18
+
19
+ return config
20
+ end
21
+
22
+ def self.process_config!(config)
23
+ config['regex'] = Regexp.new config['regex']
24
+ config['reject-regex'] = Regexp.new config['reject-regex'] if config['reject-regex']
25
+ if config['name']
26
+ c = config['name']
27
+ c['regex-ignore-parts'] ||= []
28
+ c['separator'] ||= '-'
29
+ c['prefix'] ||= ''
30
+ end
31
+
32
+ process_layout config['layout']['default']
33
+ config['layout']['group-layouts'].each { |k, v| process_layout v }
34
+ end
35
+
36
+ def self.process_layout(config)
37
+ if config['tmux']
38
+ config['tmux']['max-panes'] ||= 9
39
+ else
40
+ config['custom']['panes-flow'] ||= 'horizontal'
41
+ end
42
+ end
43
+
44
+ def self.validate_config(config)
45
+ # TODO
46
+ end
47
+ end
@@ -0,0 +1,36 @@
1
+ module TmuxConnector
2
+ class Host
3
+ attr_reader :ssh_name
4
+ attr_reader :display_name
5
+ attr_reader :group_id
6
+ attr_reader :sort_value
7
+
8
+ def initialize(name, config)
9
+ @ssh_name = name
10
+
11
+ groups = name.match(config['regex'])[1..-1]
12
+ @display_name = create_display_name groups, config
13
+ @sort_value = config['regex-parts-to']['sort-by'].map { |i| groups[i] }.join '-'
14
+ @group_id = config['regex-parts-to']['group-by'].map { |i| groups[i] }.join '-'
15
+ end
16
+
17
+ def to_s()
18
+ return "<host::#{ display_name }>"
19
+ end
20
+
21
+ private
22
+
23
+ def create_display_name groups, config
24
+ if config['name']
25
+ parts = []
26
+ groups.each_with_index do |e, i|
27
+ parts << e unless config['name']['regex-ignore-parts'].include? i
28
+ end
29
+
30
+ return config['name']['prefix'] + parts.join(config['name']['separator'])
31
+ end
32
+
33
+ return @ssh_name
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,71 @@
1
+ module TmuxConnector
2
+ class Layout
3
+ attr_reader :groups
4
+ attr_reader :merge_rules
5
+ attr_reader :merged_groups
6
+ attr_reader :raw_config
7
+ attr_reader :windows
8
+
9
+ def initialize(config, groups, merge_rules)
10
+ @raw_config = config
11
+ @groups = groups
12
+ @merge_rules = merge_rules
13
+
14
+ @windows = []
15
+
16
+ generate
17
+ end
18
+
19
+ private
20
+
21
+ def generate()
22
+ config = process_layout_config
23
+
24
+ @merged_groups = {}
25
+ merge_rules.each do |k, v|
26
+ raise "group '#{ k }' not found" if groups[k].nil?
27
+ @merged_groups[v] ||= []
28
+ @merged_groups[v].concat groups[k]
29
+ end
30
+
31
+ merged_groups.each do |name, hosts|
32
+ add_group_to_layout name, hosts, (config[name] || config['default'])
33
+ end
34
+ end
35
+
36
+ def process_layout_config()
37
+ { 'default' => raw_config['default'] }.merge raw_config['group-layouts']
38
+ end
39
+
40
+ def add_group_to_layout(group_name, hosts, config)
41
+ if config['custom']
42
+ n = config['custom']['max-horizontal'] * config['custom']['max-vertical']
43
+ else
44
+ n = config['tmux']['max-panes']
45
+ end
46
+
47
+ hosts.each_slice(n).with_index do |arr, i|
48
+ window = {
49
+ name: "#{ group_name }##{ i + 1 }",
50
+ group_name: group_name,
51
+ group_index: i + 1
52
+ }
53
+
54
+ if config['tmux']
55
+ window[:tmux] = config['tmux']['layout']
56
+ window[:panes] = arr
57
+ else
58
+ window[:flow] = config['custom']['panes-flow']
59
+
60
+ if window[:flow] == 'horizontal'
61
+ window[:panes] = arr.each_slice(config['custom']['max-horizontal']).to_a
62
+ else
63
+ window[:panes] = arr.each_slice(config['custom']['max-vertical']).to_a
64
+ end
65
+ end
66
+
67
+ windows << window
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,101 @@
1
+ require 'fileutils'
2
+
3
+
4
+ module TmuxConnector
5
+ BASE_DIR = File.expand_path '~/.tmux-connector'
6
+ MAIN_FILE = File.join BASE_DIR, '_sessions.yml'
7
+ SESSION_BASE_NAME = "s#"
8
+
9
+ def self.save_session(session_name, session_obj)
10
+ prepare_if_necessary
11
+
12
+ created = Time.now.strftime('%Y-%m-%d %H:%M')
13
+
14
+ file = File.join(BASE_DIR, "#{ session_name.gsub(/[^a-zA-Z0-9_-]+/, '-') }.bin")
15
+ file.sub!('.bin', "__#{ created.gsub(/[ :]/, '_') }.bin") if File.exists? file
16
+
17
+ update_main_file(session_name, created, file, session_obj.args['--purpose'])
18
+
19
+ open(file, 'wb') { |f| Marshal.dump session_obj, f }
20
+ end
21
+
22
+ def self.load_session(session_name)
23
+ data = list_sessions
24
+ raise "session not found: '#{ session_name }'" if data[session_name].nil?
25
+
26
+ file = data[session_name]['file']
27
+ raise "session file (#{ file }) not found" unless File.exist? file
28
+
29
+ session = nil
30
+ open(file, 'rb') { |f| session = Marshal.load f }
31
+ return session
32
+ end
33
+
34
+ def self.prepare_if_necessary()
35
+ unless File.exists?(BASE_DIR) && File.directory?(BASE_DIR)
36
+ Dir.mkdir(BASE_DIR)
37
+ end
38
+
39
+ FileUtils.touch MAIN_FILE unless File.exists? MAIN_FILE
40
+ end
41
+
42
+ def self.delete_all()
43
+ FileUtils.rm_rf BASE_DIR
44
+ end
45
+
46
+ def self.update_main_file(session_name, created, file, purpose)
47
+ data = list_sessions
48
+
49
+ data[session_name] = {
50
+ 'created' => created,
51
+ 'file' => file
52
+ }
53
+ data[session_name]['purpose'] = purpose if purpose
54
+
55
+ open(MAIN_FILE, 'w') { |f| f.write data.to_yaml }
56
+
57
+ return file
58
+ end
59
+
60
+ def self.delete_session(session_name)
61
+ data = list_sessions
62
+ raise "session not found: '#{ session_name }'" if data[session_name].nil?
63
+
64
+ file = data[session_name]['file']
65
+ data.delete session_name
66
+ open(MAIN_FILE, 'w') { |f| f.write data.to_yaml }
67
+ File.delete(file) rescue nil
68
+
69
+ end
70
+
71
+ def self.list_sessions()
72
+ raise "session file (#{ MAIN_FILE }) not found" unless File.exist? MAIN_FILE
73
+ return ( YAML.load_file(MAIN_FILE) rescue {} ) || {}
74
+ end
75
+
76
+ def self.get_new_session_name(args)
77
+ specified = args["--session-name"]
78
+
79
+ if File.exists? MAIN_FILE
80
+ existing_names = list_sessions.keys
81
+
82
+ if specified
83
+ raise "session with name '#{ specified }' already exists." if existing_names.include? specified
84
+ name = specified
85
+ else
86
+ re = /#{ SESSION_BASE_NAME }(\d+)/
87
+
88
+ last_index = existing_names.reduce(0) do |acc, e|
89
+ index = ( e.match(re)[1].to_i rescue 0 )
90
+ [ acc, index].max
91
+ end
92
+
93
+ name = "#{ SESSION_BASE_NAME }#{ last_index + 1 }"
94
+ end
95
+ else
96
+ name = specified || "#{ SESSION_BASE_NAME }1"
97
+ end
98
+
99
+ return name
100
+ end
101
+ end
@@ -0,0 +1,38 @@
1
+ require_relative 'layout'
2
+ require_relative 'persistence_handler'
3
+ require_relative 'tmux_handler'
4
+
5
+
6
+ module TmuxConnector
7
+ class Session
8
+ def self.load_by_name(name)
9
+ return TmuxConnector.load_session name
10
+ end
11
+
12
+ attr_reader :args
13
+ attr_reader :config
14
+ attr_reader :name
15
+ attr_reader :merge_rules
16
+ attr_reader :tmux_session
17
+ attr_reader :windows
18
+
19
+ def initialize(config, args, groups, merge_rules)
20
+ @config = config
21
+ @args = args
22
+ @merge_rules = merge_rules
23
+
24
+ @name = TmuxConnector.get_new_session_name(args)
25
+ @windows = Layout.new(config['layout'], groups, merge_rules).windows
26
+
27
+ @tmux_session = TmuxSession.new self
28
+ end
29
+
30
+ def start()
31
+ tmux_session.start_session
32
+ end
33
+
34
+ def save()
35
+ TmuxConnector.save_session name, self
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,15 @@
1
+ module SSHConfig
2
+ HOST_REGEX = /^Host (.+)$/
3
+
4
+ def self.get_hosts(config_file, reject_re=nil)
5
+ hosts = read_config(config_file).scan(HOST_REGEX).map(&:first).map(&:strip)
6
+ hosts.reject! { |e| e.match reject_re } if reject_re
7
+ return hosts
8
+ end
9
+
10
+ def self.read_config(config_file)
11
+ full_path = File.expand_path config_file
12
+ raise "ssh config file (#{config_file}) not found" unless File.exist? full_path
13
+ return open(full_path).read
14
+ end
15
+ end
@@ -0,0 +1,173 @@
1
+ module TmuxConnector
2
+ def self.delete_tmux_session(name)
3
+ system "tmux kill-session -t #{ name } &> /dev/null"
4
+ end
5
+
6
+ def self.delete_all_tmux_sessions()
7
+ sessions_list = %x( tmux list-sessions &> /dev/null )
8
+ sessions = sessions_list.scan(/^([^:]+): /).map(&:first)
9
+ sessions.each { |e| delete_tmux_session e }
10
+ end
11
+
12
+
13
+ class TmuxSession
14
+ attr_reader :name
15
+ attr_reader :session
16
+ attr_accessor :commands
17
+
18
+ def initialize(session)
19
+ @session = session
20
+
21
+ @name = session.name
22
+ @commands = []
23
+ end
24
+
25
+ def start_session()
26
+ create_session
27
+ create_windows
28
+ create_panes
29
+ clear_panes
30
+
31
+ connect
32
+
33
+ attach_to_session
34
+
35
+ execute
36
+ end
37
+
38
+ def send_commands(send_commands, server_regex, group_regex, verbose)
39
+ count = 0
40
+ each_pane do |window_index, pane_index, host|
41
+ if( (server_regex.nil? || host.ssh_name.match(server_regex)) &&
42
+ (group_regex.nil? || host.group_id.match(group_regex) ||
43
+ session.merge_rules[host.group_id].match(group_regex)) )
44
+ system("tmux send-keys -t #{ name }:#{ window_index }.#{ pane_index } '#{ send_commands }' C-m")
45
+ count += 1
46
+ end
47
+ end
48
+
49
+ puts "command sent to #{ count } server[s]" if verbose
50
+ end
51
+
52
+ private
53
+
54
+ def execute()
55
+ commands.each { |e| system e }
56
+ end
57
+
58
+ def create_session()
59
+ commands << <<HERE
60
+ tmux start-server
61
+
62
+ tmux has-session -t #{ name } &> /dev/null
63
+ [ $? -eq 0 ] && tmux kill-session -t #{ name }
64
+
65
+ tmux new-session -s #{ name } -n RENAME -d
66
+ HERE
67
+ end
68
+
69
+ def create_windows()
70
+ session.windows.each_with_index do |w, i|
71
+ if i == 0
72
+ commands << "tmux rename-window -t #{ name }:0 #{ w[:name] }"
73
+ else
74
+ commands << "tmux new-window -t #{ name }:#{ i } -n #{ w[:name] }"
75
+ end
76
+ end
77
+ end
78
+
79
+ def create_panes()
80
+ session.windows.each_with_index do |w, wi|
81
+ commands << "tmux select-window -t #{ name }:0"
82
+ if w[:tmux]
83
+ w[:panes].each_with_index do |p, pi|
84
+ # size is specified so panes are not to small and cause errors
85
+ size = (100.0 * (w[:panes].size - pi - 1) / (w[:panes].size - pi)).round
86
+
87
+ commands << "tmux split-window -p #{ size } -t #{ name }:#{ wi }" unless pi == 0
88
+ commands << tmux_set_title_cmd(p.display_name, wi, pi)
89
+ end
90
+
91
+ commands << "tmux select-layout -t #{ name }:#{ wi } #{ w[:tmux] } &> /dev/null"
92
+ else
93
+ create_custom_layout w, wi
94
+ end
95
+
96
+ commands << "tmux select-pane -t #{ name }:#{ wi }.0"
97
+ end
98
+ end
99
+
100
+ def clear_panes()
101
+ each_pane do |window_index, pane_index|
102
+ commands << "tmux send-keys -t #{ name }:#{ window_index }.#{ pane_index } clear C-m"
103
+ end
104
+ end
105
+
106
+ def connect()
107
+ ssh_config_path = File.expand_path session.args['--ssh-config']
108
+
109
+ each_pane do |window_index, pane_index, host|
110
+ ssh_command = "ssh -F #{ ssh_config_path } #{ host.ssh_name }"
111
+ commands << "tmux send-keys -t #{ name }:#{ window_index }.#{ pane_index } '#{ ssh_command }' C-m"
112
+ end
113
+ end
114
+
115
+ def attach_to_session()
116
+ commands << <<HERE
117
+ tmux select-pane -t #{ name }:0.0
118
+ tmux select-window -t #{ name }:0
119
+ tmux attach -t #{ name }
120
+ HERE
121
+ end
122
+
123
+ def each_pane(&block)
124
+ session.windows.each_with_index do |window, window_index|
125
+ if window[:tmux]
126
+ window[:panes].each_with_index do |host, pane_index|
127
+ yield(window_index, pane_index, host)
128
+ end
129
+ else
130
+ pane_index = 0
131
+ window[:panes].each do |g|
132
+ g.each do |host|
133
+ yield(window_index, pane_index, host)
134
+ pane_index += 1
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ def create_custom_layout(window, window_index)
142
+ direction = (window[:flow] == 'horizontal') ? ['-h', '-v'] : ['-v', '-h']
143
+
144
+ pane_index = 0
145
+ window[:panes].each_with_index do |group, group_index|
146
+ commands << "tmux select-pane -t #{ name }:#{ window_index }.#{ pane_index }"
147
+
148
+ # create pane in a next row ahead of time so pane indexes match hosts
149
+ if group_index < window[:panes].size - 1
150
+ size = (100.0 * (window[:panes].size - group_index - 1) / (window[:panes].size - group_index)).round
151
+ commands << "tmux split-window #{ direction[1] } -p #{ size } -t #{ name }:#{ window_index }"
152
+ display_name = window[:panes][group_index + 1][0].display_name
153
+ commands << tmux_set_title_cmd(display_name, window_index, -1)
154
+ commands << "tmux select-pane -t #{ name }:#{ window_index }.#{ pane_index }"
155
+ end
156
+
157
+ group.each_with_index do |host, host_index|
158
+ size = (100.0 * (group.size - host_index) / (group.size - host_index + 1)).round
159
+ commands << "tmux split-window #{ direction[0] } -p #{ size } -t #{ name }:#{ window_index }" unless host_index == 0
160
+ commands << tmux_set_title_cmd(host.display_name, window_index, pane_index)
161
+
162
+ pane_index += 1
163
+ end
164
+ end
165
+ end
166
+
167
+ def tmux_set_title_cmd(title, window_id, pane_id) # pane_id == -1 -> do not specify
168
+ keys = %q|printf '\033]2;%s\033\\'| + " '#{ title }'"
169
+ pane_id_str = (pane_id == -1) ? '' : ".#{ pane_id }"
170
+ return %Q|tmux send-keys -t #{ name }:#{ window_id }#{ pane_id_str } "#{ keys }" C-m|
171
+ end
172
+ end
173
+ end
@@ -0,0 +1,3 @@
1
+ module TmuxConnector
2
+ VERSION = "0.0.2"
3
+ end
@@ -0,0 +1,56 @@
1
+ require 'docopt'
2
+
3
+ require_relative 'tmux-connector/version'
4
+ require_relative 'tmux-connector/command_handler'
5
+
6
+
7
+ module TmuxConnector
8
+ TCON_DOC = <<HERE
9
+ tcon enables establishing connections (ssh) to multiple servers and executing
10
+ commands on those servers. The sessions can be persisted (actually recreated)
11
+ even after computer restarts. Complex sessions with different layouts for
12
+ different kinds of servers can be easily created.
13
+
14
+ Usage:
15
+ tcon start <config-file> [--ssh-config=<file>]
16
+ [--session-name=<name>] [--purpose=<description>]
17
+ tcon resume <session-name>
18
+ tcon delete (<session-name> | --all)
19
+ tcon list
20
+ tcon send <session-name> (<command> | --command-file=<file>)
21
+ [--server-filter=<regex>] [--group-filter=<regex>] [--verbose]
22
+ tcon --help
23
+ tcon --version
24
+
25
+ Options:
26
+ <config-file> Path to configuration file. Configuration file
27
+ describes how new session is started. YAML format.
28
+ <session-name> Name that identifies the session. Must be unique.
29
+ <command> Command to be executed on remote server[s].
30
+ -s --ssh-config=file Path to ssh config file [default: ~/.ssh/config].
31
+ -n --session-name=name Name of the session to be used in the tcon command.
32
+ -p --purpose=description Description of session's purpose.
33
+ --all Delete all existing sessions.
34
+ -f --server-filter=regex Filter to select a subset of the servers.
35
+ Should be valid ruby regex.
36
+ -g --group-filter=regex Filter to select a subset of the servers via
37
+ group membership. Should be valid ruby regex.
38
+ -c --command-file=file File containing the list of commands to be
39
+ executed on remote server[s].
40
+ -v --verbose Report how many servers were affected by the send
41
+ command.
42
+ -h --help Show this screen.
43
+ --version Show version.
44
+ HERE
45
+
46
+ def self.main(input_args)
47
+ begin
48
+ args = Docopt.docopt TCON_DOC, argv: input_args, version: VERSION
49
+ process_command args
50
+ rescue Docopt::Exit => e
51
+ puts e.message
52
+ rescue => e
53
+ puts "Something went wrong: #{ e.message }"
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,21 @@
1
+ # -*- encoding: utf-8 -*-
2
+ lib = File.expand_path('../lib', __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require 'tmux-connector/version'
5
+
6
+ Gem::Specification.new do |gem|
7
+ gem.name = "tmux-connector"
8
+ gem.version = TmuxConnector::VERSION
9
+ gem.authors = ["Ivan Kusalic"]
10
+ gem.email = ["ikusalic@gmail.com"] # TODO
11
+ gem.summary = %q{Manage multiple servers using SSH and tmux.}
12
+ gem.description = %q{tcon enables establishing connections (ssh) to multiple servers and executing commands on those servers. The sessions can be persisted (actually recreated) even after computer restarts. Complex sessions with different layouts for different kinds of servers can be easily created.}
13
+ gem.homepage = "http://github.com/ikusalic" # TODO
14
+
15
+ gem.add_dependency('docopt')
16
+
17
+ gem.files = `git ls-files`.split($/)
18
+ gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) }
19
+ gem.test_files = gem.files.grep(%r{^(test|spec|features)/})
20
+ gem.require_paths = ["lib"]
21
+ end
metadata ADDED
@@ -0,0 +1,88 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: tmux-connector
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.0.2
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - Ivan Kusalic
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-05-14 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: docopt
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :runtime
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ description: tcon enables establishing connections (ssh) to multiple servers and executing
31
+ commands on those servers. The sessions can be persisted (actually recreated) even
32
+ after computer restarts. Complex sessions with different layouts for different kinds
33
+ of servers can be easily created.
34
+ email:
35
+ - ikusalic@gmail.com
36
+ executables:
37
+ - tcon
38
+ extensions: []
39
+ extra_rdoc_files: []
40
+ files:
41
+ - .gitignore
42
+ - Gemfile
43
+ - LICENSE.txt
44
+ - README.md
45
+ - Rakefile
46
+ - TODO.md
47
+ - bin/tcon
48
+ - lib/tmux-connector.rb
49
+ - lib/tmux-connector/command_handler.rb
50
+ - lib/tmux-connector/commands/delete.rb
51
+ - lib/tmux-connector/commands/list.rb
52
+ - lib/tmux-connector/commands/resume.rb
53
+ - lib/tmux-connector/commands/send.rb
54
+ - lib/tmux-connector/commands/start.rb
55
+ - lib/tmux-connector/config_handler.rb
56
+ - lib/tmux-connector/host.rb
57
+ - lib/tmux-connector/layout.rb
58
+ - lib/tmux-connector/persistence_handler.rb
59
+ - lib/tmux-connector/session.rb
60
+ - lib/tmux-connector/ssh_config_parser.rb
61
+ - lib/tmux-connector/tmux_handler.rb
62
+ - lib/tmux-connector/version.rb
63
+ - tmux-connector.gemspec
64
+ homepage: http://github.com/ikusalic
65
+ licenses: []
66
+ post_install_message:
67
+ rdoc_options: []
68
+ require_paths:
69
+ - lib
70
+ required_ruby_version: !ruby/object:Gem::Requirement
71
+ none: false
72
+ requirements:
73
+ - - ! '>='
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ required_rubygems_version: !ruby/object:Gem::Requirement
77
+ none: false
78
+ requirements:
79
+ - - ! '>='
80
+ - !ruby/object:Gem::Version
81
+ version: '0'
82
+ requirements: []
83
+ rubyforge_project:
84
+ rubygems_version: 1.8.25
85
+ signing_key:
86
+ specification_version: 3
87
+ summary: Manage multiple servers using SSH and tmux.
88
+ test_files: []