runssh 0.1.0

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,287 @@
1
+ #
2
+ # Copyright (C) 2010 Haim Ashkenazi
3
+ #
4
+ # This program is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU General Public License
6
+ # as published by the Free Software Foundation; either version 2
7
+ # of the License, or (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program; if not, write to the Free Software
16
+ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
+ #
18
+ require 'trollop'
19
+
20
+ module RunSSHLib
21
+ class CLI
22
+
23
+ COMMAND = %w(shell add del update print import export)
24
+
25
+ # Initialize new CLI instance and parse the supplied
26
+ # arguments.
27
+ def initialize(args)
28
+ args.unshift '-h' if args.empty?
29
+ args.unshift '-h' if args == ['help']
30
+ @global_options = parse_args(args)
31
+
32
+ # workaround to enable 'help COMMAND' functionality.
33
+ if args.first == 'help'; args.shift; args << '-h'; end
34
+ # indicate path completion request
35
+ @completion_requested = args.delete('?')
36
+
37
+ @cmd = extract_subcommand(args)
38
+ @options = parse_subcommand(@cmd, args)
39
+ @c = init_config
40
+ # path in ConfigFile uses symbols and not strings
41
+ @path = args.map { |e| e.to_sym }
42
+ rescue ConfigError, InvalidSubCommandError, Errno::ENOENT => e
43
+ Trollop.die e.message
44
+ end
45
+
46
+ # run
47
+ def run
48
+ # did the user request completions? if not run the approproate command.
49
+ if @completion_requested
50
+ puts @c.list_groups(@path)
51
+ else
52
+ command_name = 'run_' + @cmd
53
+ m = method(command_name.to_sym)
54
+ m.call(@path)
55
+ end
56
+ rescue ConfigError => e
57
+ Trollop.die e.message
58
+ end
59
+
60
+ private
61
+
62
+ # Parses main arguments. Returns the output of Trollop::options
63
+ def parse_args(args)
64
+ Trollop::options(args) do
65
+ # TODO: This should be generated automatically somehow!!
66
+ banner <<-EOS
67
+ Usage: runssh [global_options] COMMAND [options] <path>
68
+
69
+ A utility to bookmark multiple ssh connections in heirarchial order.
70
+
71
+ COMMAND : One of the commands mentioned below. It's possible to
72
+ type only part of the command as long as it's not ambiguous.
73
+ <path> : A space separated list of names (e.g, one two three)
74
+ For available completions append " ?" to the end of path.
75
+
76
+ Available commands:
77
+ * shell : Open ssh shell on remote host
78
+ * add : Add host definition
79
+ * del : Delete host definition
80
+ * update : Update host definition
81
+ * print : Print host definition
82
+ * import : Import configuration
83
+ * export : Export configuration
84
+
85
+ For help on commands run:
86
+ runssh help COMMAND
87
+
88
+ Global options:
89
+ EOS
90
+ opt :config_file, "alternate config file",
91
+ :type => :string, :short => :f
92
+ version "RunSSH version #{Version::STRING}"
93
+ stop_on_unknown
94
+ end
95
+ end
96
+
97
+ # Etracts the subcommand from args. Throws InvalidSubCommandError if
98
+ # invalid or ambigious subcommand
99
+ def extract_subcommand(args)
100
+ cmd = args.shift
101
+ if COMMAND.include? cmd
102
+ cmd
103
+ else
104
+ cmdopts = COMMAND.select { |item| item =~ /^#{cmd}/ }
105
+ raise InvalidSubCommandError, 'invalid command' unless
106
+ cmdopts.length == 1
107
+ cmdopts.first
108
+ end
109
+ rescue RegexpError
110
+ raise InvalidSubCommandError, 'invalid command'
111
+ end
112
+
113
+ # handles argument parsing for all subcomand. It doesn't contain
114
+ # any logic, nor does it handle errors. It just parses the
115
+ # arguments and put the result into @options.
116
+ def parse_subcommand(cmd, args)
117
+ case cmd
118
+ when 'shell'
119
+ Trollop::options(args) do
120
+ banner <<-EOS
121
+ Usage: runssh [global_options] shell [options] <path>
122
+
123
+ Connect to the specified host using ssh.
124
+
125
+ <path> : See main help for description of path.
126
+
127
+ Options:
128
+ EOS
129
+ opt :login, "override the login in the configuration",
130
+ :type => :string
131
+ end
132
+ when 'add'
133
+ Trollop::options(args) do
134
+ banner <<-EOS
135
+ Usage: runssh [global_options] add [options] <path>
136
+
137
+ Add a new host definition at the supplied <path>. <path> must not exit!
138
+
139
+ <path> : See main help for description of path.
140
+
141
+ Options:
142
+ EOS
143
+ opt :host_name, 'The name or address of the host (e.g, host.example.com)',
144
+ :short => :n, :type => :string, :required => true
145
+ opt :user, 'The user to connect as (optional)',
146
+ :short => :u, :type => :string
147
+ end
148
+ when 'update'
149
+ Trollop::options(args) do
150
+ banner <<-EOS
151
+ Usage: runssh [global_options] update [options] <path>
152
+
153
+ Update host definition specified by <path> with new settings. The host
154
+ definition is completely replaced by the new definition (e.g, You can
155
+ not specify only new host and expect the user to remain the old one).
156
+
157
+ <path> : See main help for description of path.
158
+
159
+ Options:
160
+ EOS
161
+ opt :host_name, 'The name or address of the host (e.g, host.example.com)',
162
+ :short => :n, :type => :string, :required => true
163
+ opt :user, 'The user to connect as (optional)',
164
+ :short => :u, :type => :string
165
+ end
166
+ when 'del'
167
+ Trollop::options(args) do
168
+ banner <<-EOS
169
+ Usage: runssh [global_options] del [options] <path>
170
+
171
+ Delete host definitions or `empty` groups (e.g, groups that contained
172
+ only one host definition which was deleted). You'll be prompted for
173
+ verification.
174
+
175
+ <path> : See main help for description of path.
176
+
177
+ Options:
178
+ EOS
179
+ opt :yes, 'Delete without verification'
180
+ end
181
+ when 'print'
182
+ Trollop::options(args) do
183
+ banner <<-EOS
184
+ Usage: runssh [global_options] print [options] <path>
185
+
186
+ Print host configuration to the console.
187
+
188
+ <path> : See main help for description of path.
189
+
190
+ Options:
191
+ EOS
192
+ end
193
+ when 'import'
194
+ Trollop::options(args) do
195
+ banner <<-EOS
196
+ Usage: runssh [global_options] import [options]
197
+
198
+ Imports a new configuration.
199
+ CAREFULL: This completely overrides the current configuration!
200
+
201
+ Options:
202
+ EOS
203
+ opt :input_file, 'The yaml file to import from',
204
+ :type => :string, :required => true
205
+ end
206
+ when 'export'
207
+ Trollop::options(args) do
208
+ banner <<-EOS
209
+ Usage runssh [global_options] export [options]
210
+
211
+ Exports the configuration to a YAML file.
212
+
213
+ Options
214
+ EOS
215
+ opt :output_file, 'The output file',
216
+ :type => :string, :required => true
217
+ end
218
+ end
219
+ end
220
+
221
+ def init_config
222
+ config = @global_options[:config_file] ?
223
+ @global_options[:config_file] : DEFAULT_CONFIG
224
+ ConfigFile.new(config)
225
+ end
226
+
227
+ def run_shell(path)
228
+ host = @c.get_host(path)
229
+ s = SshBackend.new(host, @options)
230
+ s.shell
231
+ end
232
+
233
+ def run_add(path)
234
+ # extract the host definition name
235
+ host = path.pop
236
+ @c.add_host_def(path, host,
237
+ HostDef.new(@options[:host_name], @options[:user]))
238
+ end
239
+
240
+ def run_update(path)
241
+ @c.update_host_def(path,
242
+ HostDef.new(@options[:host_name], @options[:user]))
243
+ end
244
+
245
+ def run_del(path)
246
+ question = "Are you sure you want to delete \"" + path.join(':') + "\""
247
+ if verify_yn(question)
248
+ @c.delete_path(path)
249
+ else
250
+ puts 'canceled'
251
+ end
252
+ end
253
+
254
+ def run_print(path)
255
+ host = @c.get_host(path)
256
+ output = "Host definition for: #{path.last}",
257
+ " * host: #{host.name}",
258
+ " * user: #{host.login ? host.login : 'current user'}"
259
+ puts output
260
+ end
261
+
262
+ # we don't use path here, it's just for easier invocation.
263
+ def run_import(path)
264
+ question = "Importing a file OVERWRITES existing configuration. " +
265
+ "Are you sure"
266
+ if verify_yn(question)
267
+ @c.import(@options[:input_file])
268
+ else
269
+ puts 'canceled'
270
+ end
271
+ end
272
+
273
+ # we don't use path here, it's just for easier invocation
274
+ def run_export(path)
275
+ @c.export(@options[:output_file])
276
+ end
277
+
278
+ # Verifies a presented question. If response is 'y' it returns
279
+ # true, else false.
280
+ #
281
+ # The supplied question should not include the (y/n)? postfix.
282
+ def verify_yn question
283
+ print question, " (y/n)? "
284
+ gets.chomp == 'y'
285
+ end
286
+ end
287
+ end
@@ -0,0 +1,167 @@
1
+ #
2
+ # Copyright (C) 2010 Haim Ashkenazi
3
+ #
4
+ # This program is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU General Public License
6
+ # as published by the Free Software Foundation; either version 2
7
+ # of the License, or (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program; if not, write to the Free Software
16
+ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
+ #
18
+
19
+ module RunSSHLib
20
+
21
+ # Handles configuration file for the application.
22
+ #
23
+ # The configuration consists of nested hashes which keys either
24
+ # points to another hash or to host definition.
25
+ #
26
+ # The configuration file should use Marshal to save/load
27
+ # configuration, but should also be able to import/export
28
+ # to/from yaml file.
29
+ class ConfigFile
30
+
31
+ # Initialize new ConfigFile. Uses supplied config_file or the default
32
+ # '~/.runssh'. If file doesn't exist, it issues a warning and creates
33
+ # a new empty one.
34
+ def initialize(config_file)
35
+ @config_file = config_file
36
+ if File.exists? config_file
37
+ File.open(config_file) { |io| @config = Marshal.load(io) }
38
+ else
39
+ # warn "Config file not found. It must be the first time you run this app..."
40
+ @config = Hash.new
41
+ save
42
+ end
43
+ end
44
+
45
+ # Add host definition to config file.
46
+ #
47
+ # path:: An array of symbols that represent the path
48
+ # for the host. e.g, [:client, :datacenter1].
49
+ # name:: The name of the host definition as symbol.
50
+ # host_def:: A HostDef instance.
51
+ def add_host_def(path, name, host_def)
52
+ # sanity
53
+ raise ConfigError.new('Invalid host definition') unless host_def.instance_of? HostDef
54
+
55
+ k = path.inject(@config) do |hsh, key|
56
+ if hsh.include? key
57
+ if hsh[key].instance_of? HostDef
58
+ raise ConfigError.new('Cannot override host definition with path!')
59
+ end
60
+ hsh[key]
61
+ else
62
+ hsh[key] = {}
63
+ end
64
+ end
65
+
66
+ raise ConfigError.new('path already exist!') if k.include? name
67
+
68
+ k[name] = host_def
69
+ save
70
+ end
71
+
72
+ # Update host definition (host_def) at the specified path.
73
+ # Raises ConfigError if doesn't already exist!
74
+ def update_host_def(path, host_def)
75
+ # sanity
76
+ raise ConfigError.new('Invalid host definition!') if not
77
+ host_def.instance_of? HostDef
78
+
79
+ # we need to separate the host name from the path
80
+ # in order to get the key of the host definition.
81
+ host = path.pop
82
+ groups = retrieve_path(path, "Invalid path!")
83
+ raise ConfigError, 'Invalid path!' unless groups
84
+ if groups.include? host
85
+ raise ConfigError.new("Cannot overwrite group with host definition") unless
86
+ groups[host].instance_of? HostDef
87
+ groups[host] = host_def
88
+ else
89
+ raise ConfigError.new("Host definition doesn't exist!")
90
+ end
91
+ save
92
+ end
93
+
94
+ # Returns the host definition in the specified path.
95
+ # path:: is an array of symbols which translates to nested hash keys.
96
+ # Raises:: ConfigError if not found or if path points to a group.
97
+ def get_host(path)
98
+ host = retrieve_path(path,
99
+ %Q{host definition (#{path.join(' => ')}) doesn't exist!})
100
+ if not host
101
+ raise ConfigError.new(%Q{host definition (#{path.join(' => ')}) doesn't exist!})
102
+ elsif host.instance_of? Hash
103
+ raise ConfigError.new(%Q("#{path.join(' => ')}" is a group, not host definition!))
104
+ end
105
+
106
+ host
107
+ end
108
+
109
+ # List all available sub groups inside path.
110
+ def list_groups(path)
111
+ value = retrieve_path(path, 'Invalid path!')
112
+ if value.instance_of? Hash
113
+ value.keys
114
+ else
115
+ []
116
+ end
117
+ end
118
+
119
+ # This will delete any path if it's a host definition
120
+ # or an empty group.
121
+ def delete_path(path)
122
+ # we need access to the delete key, not just the value
123
+ mykey = path.pop
124
+ value = retrieve_path(path, 'Invalid path!')
125
+ raise ConfigError.new('Invalid path!') unless value
126
+
127
+ if value[mykey].instance_of? HostDef or value[mykey] == {}
128
+ value.delete(mykey)
129
+ elsif not value[mykey]
130
+ raise ConfigError.new('Invalid path!')
131
+ else
132
+ raise ConfigError.new('Supplied path is non-empty group!')
133
+ end
134
+
135
+ save
136
+ end
137
+
138
+ # Export config as YAML to the supplied file.
139
+ def import(file)
140
+ require 'yaml'
141
+ @config = YAML.load_file(file)
142
+ save
143
+ end
144
+
145
+ # Import config from YAML from the specified file.
146
+ def export(file)
147
+ require 'yaml'
148
+ File.open(file, 'w') { |out| YAML.dump(@config, out) }
149
+ end
150
+
151
+ private
152
+
153
+ def save
154
+ require 'ftools'
155
+ # create backup (File.copy always seems to overwrite existing file)
156
+ File.copy(@config_file, @config_file + '.bak') if File.exists? @config_file
157
+ File.open(@config_file, 'w') { |out| Marshal.dump(@config, out) }
158
+ end
159
+
160
+ def retrieve_path(path, error)
161
+ host = path.inject(@config) do |hsh, ky|
162
+ raise ConfigError.new(error) unless hsh
163
+ hsh[ky]
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,36 @@
1
+ #
2
+ # Copyright (C) 2010 Haim Ashkenazi
3
+ #
4
+ # This program is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU General Public License
6
+ # as published by the Free Software Foundation; either version 2
7
+ # of the License, or (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program; if not, write to the Free Software
16
+ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
+ #
18
+
19
+ module RunSSHLib
20
+
21
+ # A class to handle ssh operations.
22
+ class SshBackend
23
+ # New backend with host/login details.
24
+ def initialize(host_def, overrides)
25
+ @host = host_def.name
26
+ @user = overrides[:login] ? overrides[:login] : host_def.login
27
+ end
28
+
29
+ # run shell on remote host.
30
+ def shell
31
+ command = "ssh #{@user ? %Q(-l #{@user}) : ''} #{@host}"
32
+ exec command
33
+ end
34
+
35
+ end
36
+ end
data/lib/runsshlib.rb ADDED
@@ -0,0 +1,44 @@
1
+ #
2
+ # Copyright (C) 2010 Haim Ashkenazi
3
+ #
4
+ # This program is free software; you can redistribute it and/or
5
+ # modify it under the terms of the GNU General Public License
6
+ # as published by the Free Software Foundation; either version 2
7
+ # of the License, or (at your option) any later version.
8
+ #
9
+ # This program is distributed in the hope that it will be useful,
10
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ # GNU General Public License for more details.
13
+ #
14
+ # You should have received a copy of the GNU General Public License
15
+ # along with this program; if not, write to the Free Software
16
+ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
17
+ #
18
+
19
+ require 'runsshlib/cli'
20
+ require 'runsshlib/config_file'
21
+ require 'runsshlib/ssh_backend'
22
+
23
+ # Main RunSSHLib module.
24
+ module RunSSHLib
25
+
26
+ DEFAULT_CONFIG = File.expand_path('~/.runssh')
27
+
28
+ # Indicates configuration error
29
+ class ConfigError < StandardError; end
30
+
31
+ # Indicates invalid command
32
+ class InvalidSubCommandError < StandardError; end
33
+
34
+ # A placeholder for host definitions
35
+ HostDef = Struct.new(:name, :login)
36
+
37
+ module Version
38
+ MAJOR = 0
39
+ MINOR = 1
40
+ BUILD = 0
41
+
42
+ STRING = [MAJOR, MINOR, BUILD].compact.join('.')
43
+ end
44
+ end
@@ -0,0 +1,19 @@
1
+ ---
2
+ :cust1:
3
+ :dc1:
4
+ :host2: !ruby/struct:RunSSHLib::HostDef
5
+ name: b.host.com
6
+ login: user1
7
+ :host1: !ruby/struct:RunSSHLib::HostDef
8
+ name: a.host.com
9
+ login: user1
10
+ :dc2:
11
+ :host1: !ruby/struct:RunSSHLib::HostDef
12
+ name: c.host.com
13
+ login: user3
14
+ :cust2:
15
+ :dc:
16
+ :internal:
17
+ :somehost: !ruby/struct:RunSSHLib::HostDef
18
+ name: a.example.com
19
+ login: otheruser