tmux-connector 0.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.
- data/.gitignore +17 -0
- data/Gemfile +3 -0
- data/LICENSE.txt +22 -0
- data/README.md +352 -0
- data/Rakefile +1 -0
- data/TODO.md +12 -0
- data/bin/tcon +5 -0
- data/lib/tmux-connector/command_handler.rb +27 -0
- data/lib/tmux-connector/commands/delete.rb +24 -0
- data/lib/tmux-connector/commands/list.rb +16 -0
- data/lib/tmux-connector/commands/resume.rb +17 -0
- data/lib/tmux-connector/commands/send.rb +42 -0
- data/lib/tmux-connector/commands/start.rb +65 -0
- data/lib/tmux-connector/config_handler.rb +47 -0
- data/lib/tmux-connector/host.rb +36 -0
- data/lib/tmux-connector/layout.rb +71 -0
- data/lib/tmux-connector/persistence_handler.rb +101 -0
- data/lib/tmux-connector/session.rb +38 -0
- data/lib/tmux-connector/ssh_config_parser.rb +15 -0
- data/lib/tmux-connector/tmux_handler.rb +173 -0
- data/lib/tmux-connector/version.rb +3 -0
- data/lib/tmux-connector.rb +56 -0
- data/tmux-connector.gemspec +21 -0
- metadata +88 -0
data/.gitignore
ADDED
data/Gemfile
ADDED
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,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,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: []
|