treequel 1.0.0 → 1.0.1
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/ChangeLog +271 -1
- data/Rakefile +20 -16
- data/bin/treequel +816 -65
- data/bin/treequel.orig +963 -0
- data/examples/ldap-rack-auth.rb +101 -0
- data/lib/treequel/branchset.rb +11 -0
- data/lib/treequel/directory.rb +1 -3
- data/lib/treequel/filter.rb +1 -1
- data/lib/treequel/mixins.rb +65 -16
- data/lib/treequel/utils.rb +76 -1
- data/lib/treequel.rb +2 -2
- data/rake/manual.rb +1 -1
- data/rake/packaging.rb +9 -0
- data/rake/publishing.rb +2 -2
- data/spec/treequel/branchset_spec.rb +11 -0
- data/spec/treequel/filter_spec.rb +4 -0
- metadata +81 -48
data/bin/treequel
CHANGED
@@ -1,30 +1,108 @@
|
|
1
1
|
#!/usr/bin/env ruby
|
2
2
|
|
3
3
|
require 'rubygems'
|
4
|
-
|
4
|
+
|
5
|
+
require 'abbrev'
|
6
|
+
require 'columnize'
|
7
|
+
require 'digest/sha1'
|
5
8
|
require 'logger'
|
9
|
+
require 'open3'
|
10
|
+
require 'optparse'
|
11
|
+
require 'ostruct'
|
12
|
+
require 'pathname'
|
13
|
+
require 'readline'
|
6
14
|
require 'shellwords'
|
7
15
|
require 'tempfile'
|
8
|
-
require '
|
9
|
-
require '
|
16
|
+
require 'terminfo'
|
17
|
+
require 'termios'
|
18
|
+
require 'uri'
|
19
|
+
|
10
20
|
require 'treequel'
|
11
21
|
require 'treequel/mixins'
|
12
22
|
require 'treequel/constants'
|
13
23
|
|
14
24
|
|
15
|
-
|
16
|
-
|
17
|
-
|
25
|
+
### Monkeypatch for resetting an OpenStruct's state.
|
26
|
+
class OpenStruct
|
27
|
+
|
28
|
+
### Clear all defined fields and values.
|
29
|
+
def clear
|
30
|
+
@table.clear
|
31
|
+
end
|
32
|
+
|
33
|
+
end
|
34
|
+
|
35
|
+
|
36
|
+
# The Treequel shell.
|
37
|
+
class Treequel::Shell
|
38
|
+
include Readline,
|
39
|
+
Treequel::Loggable,
|
40
|
+
Treequel::ANSIColorUtilities,
|
41
|
+
Treequel::Constants::Patterns,
|
42
|
+
Treequel::HashUtilities
|
43
|
+
|
44
|
+
# Prompt text for #prompt_for_multiple_values
|
45
|
+
MULTILINE_PROMPT = <<-'EOF'
|
46
|
+
Enter one or more values for '%s'.
|
47
|
+
A blank line finishes input.
|
48
|
+
EOF
|
49
|
+
|
50
|
+
# Some ANSI codes for fancier stuff
|
51
|
+
CLEAR_TO_EOL = "\e[K"
|
52
|
+
CLEAR_CURRENT_LINE = "\e[2K"
|
53
|
+
|
54
|
+
# Log levels
|
55
|
+
LOG_LEVELS = {
|
56
|
+
'debug' => Logger::DEBUG,
|
57
|
+
'info' => Logger::INFO,
|
58
|
+
'warn' => Logger::WARN,
|
59
|
+
'error' => Logger::ERROR,
|
60
|
+
'fatal' => Logger::FATAL,
|
61
|
+
}.freeze
|
62
|
+
LOG_LEVEL_NAMES = LOG_LEVELS.invert.freeze
|
63
|
+
|
64
|
+
# Command option parsers
|
65
|
+
OPTION_PARSERS = {}
|
66
|
+
|
67
|
+
# Path to the default history file
|
68
|
+
HISTORY_FILE = Pathname( "~/.treequel.history" )
|
18
69
|
|
70
|
+
# Number of items to store in history by default
|
71
|
+
DEFAULT_HISTORY_SIZE = 100
|
72
|
+
|
73
|
+
|
74
|
+
#################################################################
|
75
|
+
### C L A S S M E T H O D S
|
76
|
+
#################################################################
|
77
|
+
|
78
|
+
### Create an option parser from the specified +block+ for the given +command+ and register
|
79
|
+
### it. Many thanks to apeiros and dominikh on #Ruby-Pro for the ideas behind this.
|
80
|
+
def self::set_options( command, &block )
|
81
|
+
options = OpenStruct.new
|
82
|
+
oparser = OptionParser.new( "Help for #{command}" ) do |o|
|
83
|
+
yield( o, options )
|
84
|
+
end
|
85
|
+
oparser.default_argv = []
|
86
|
+
|
87
|
+
OPTION_PARSERS[command.to_sym] = [oparser, options]
|
88
|
+
end
|
89
|
+
|
90
|
+
|
91
|
+
#################################################################
|
92
|
+
### I N S T A N C E M E T H O D S
|
93
|
+
#################################################################
|
19
94
|
|
20
95
|
### Create a new shell that will traverse the directory at the specified +uri+.
|
21
96
|
def initialize( uri )
|
22
97
|
Treequel.logger.level = Logger::WARN
|
98
|
+
Treequel.logger.formatter = Treequel::ColorLogFormatter.new( Treequel.logger )
|
23
99
|
|
24
100
|
@uri = uri
|
25
101
|
@quit = false
|
26
102
|
@dir = Treequel.directory( @uri )
|
27
103
|
@currbranch = @dir
|
104
|
+
@columns = TermInfo.screen_width
|
105
|
+
@rows = TermInfo.screen_height
|
28
106
|
|
29
107
|
@commands = self.find_commands
|
30
108
|
@completions = @commands.abbrev
|
@@ -34,41 +112,83 @@ class Shell
|
|
34
112
|
|
35
113
|
### The command loop: run the shell until the user wants to quit
|
36
114
|
def run
|
37
|
-
|
115
|
+
@original_tty_settings = IO.read( '|-' ) or exec 'stty', '-g'
|
116
|
+
message "Connected to %s" % [ @uri ]
|
38
117
|
|
118
|
+
# Set up the completion callback
|
39
119
|
self.setup_completion
|
40
120
|
|
121
|
+
# Load saved command-line history
|
122
|
+
self.read_history
|
123
|
+
|
124
|
+
# Run until something sets the quit flag
|
41
125
|
until @quit
|
42
|
-
|
126
|
+
$stderr.puts
|
127
|
+
prompt = make_prompt_string( @currbranch.dn + '> ' )
|
128
|
+
input = Readline.readline( prompt, true )
|
43
129
|
self.log.debug "Input is: %p" % [ input ]
|
44
130
|
|
45
131
|
# EOL makes the shell quit
|
46
132
|
if input.nil?
|
133
|
+
self.log.debug "EOL: setting quit flag"
|
47
134
|
@quit = true
|
48
135
|
|
136
|
+
# Blank input -- just reprompt
|
49
137
|
elsif input == ''
|
50
138
|
self.log.debug "No command. Re-displaying the prompt."
|
51
139
|
|
52
140
|
# Parse everything else into command + everything else
|
53
141
|
else
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
142
|
+
self.log.debug "Dispatching input: %p" % [ input ]
|
143
|
+
self.dispatch_cmd( input )
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
message "\nSaving history...\n"
|
148
|
+
self.save_history
|
149
|
+
|
150
|
+
message "done."
|
151
|
+
ensure
|
152
|
+
system( 'stty', @original_tty_settings.chomp )
|
153
|
+
end
|
154
|
+
|
155
|
+
|
156
|
+
### Parse the specified +input+ into a command, options, and arguments and dispatch them
|
157
|
+
### to the appropriate command method.
|
158
|
+
def dispatch_cmd( input )
|
159
|
+
command, *args = Shellwords.shellwords( input )
|
160
|
+
|
161
|
+
# If it's a valid command, run it
|
162
|
+
if meth = @command_table[ command ]
|
163
|
+
full_command = @completions[ command ].to_sym
|
164
|
+
|
165
|
+
# If there's a registered optionparser for the command, use it to
|
166
|
+
# split out options and arguments, then pass those to the command.
|
167
|
+
if OPTION_PARSERS.key?( full_command )
|
168
|
+
oparser, options = OPTION_PARSERS[ full_command ]
|
169
|
+
self.log.debug "Got an option-parser for #{full_command}."
|
170
|
+
|
171
|
+
cmdargs = oparser.parse( args )
|
172
|
+
self.log.debug " options=%p, args=%p" % [ options, cmdargs ]
|
173
|
+
meth.call( options, *cmdargs )
|
174
|
+
|
175
|
+
options.clear
|
176
|
+
|
177
|
+
# ...otherwise just call it with all the args.
|
178
|
+
else
|
179
|
+
meth.call( *args )
|
68
180
|
end
|
181
|
+
|
182
|
+
# ...otherwise call the fallback handler
|
183
|
+
else
|
184
|
+
self.handle_missing_cmd( command )
|
69
185
|
end
|
70
186
|
|
71
|
-
|
187
|
+
rescue => err
|
188
|
+
error_message( err.class.name, err.message )
|
189
|
+
err.backtrace.each do |frame|
|
190
|
+
self.log.debug " " + frame
|
191
|
+
end
|
72
192
|
end
|
73
193
|
|
74
194
|
|
@@ -80,33 +200,110 @@ class Shell
|
|
80
200
|
def setup_completion
|
81
201
|
Readline.completion_proc = self.method( :completion_callback ).to_proc
|
82
202
|
Readline.completer_word_break_characters = ''
|
203
|
+
Readline.basic_word_break_characters = ''
|
204
|
+
end
|
205
|
+
|
206
|
+
|
207
|
+
### Read command line history from HISTORY_FILE
|
208
|
+
def read_history
|
209
|
+
histfile = HISTORY_FILE.expand_path
|
210
|
+
|
211
|
+
if histfile.exist?
|
212
|
+
lines = histfile.readlines.collect {|line| line.chomp }
|
213
|
+
self.log.debug "Read %d saved history commands from %s." % [ lines.nitems, histfile ]
|
214
|
+
Readline::HISTORY.push( *lines )
|
215
|
+
else
|
216
|
+
self.log.debug "History file '%s' was empty or non-existant." % [ histfile ]
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
|
221
|
+
### Save command line history to HISTORY_FILE
|
222
|
+
def save_history
|
223
|
+
histfile = HISTORY_FILE.expand_path
|
224
|
+
|
225
|
+
lines = Readline::HISTORY.to_a.reverse.uniq.reverse
|
226
|
+
lines = lines[ -DEFAULT_HISTORY_SIZE, DEFAULT_HISTORY_SIZE ] if
|
227
|
+
lines.nitems > DEFAULT_HISTORY_SIZE
|
228
|
+
|
229
|
+
self.log.debug "Saving %d history lines to %s." % [ lines.length, histfile ]
|
230
|
+
|
231
|
+
histfile.open( File::WRONLY|File::CREAT|File::TRUNC ) do |ofh|
|
232
|
+
ofh.puts( *lines )
|
233
|
+
end
|
83
234
|
end
|
84
235
|
|
85
236
|
|
86
237
|
### Handle completion requests from Readline.
|
87
238
|
def completion_callback( input )
|
88
|
-
|
89
|
-
|
239
|
+
self.log.debug "Input completion: %p" % [ input ]
|
240
|
+
parts = Shellwords.shellwords( input )
|
241
|
+
|
242
|
+
# If there aren't any arguments, it's command completion
|
243
|
+
if parts.length == 1
|
244
|
+
# One completion means it's an unambiguous match, so just complete it.
|
245
|
+
possible_completions = @commands.grep( /^#{Regexp.quote(input)}/ ).sort
|
246
|
+
self.log.debug " possible completions: %p" % [ possible_completions ]
|
247
|
+
return possible_completions
|
248
|
+
else
|
249
|
+
incomplete = parts.pop
|
250
|
+
self.log.debug " the incomplete bit is: %p" % [ incomplete ]
|
251
|
+
possible_completions = @currbranch.children.
|
252
|
+
collect {|br| br.rdn }.grep( /^#{Regexp.quote(incomplete)}/i ).sort
|
253
|
+
|
254
|
+
possible_completions.map! do |lastpart|
|
255
|
+
parts.join( ' ' ) + ' ' + lastpart
|
256
|
+
end
|
257
|
+
|
258
|
+
self.log.debug " possible (argument) completions: %p" % [ possible_completions ]
|
259
|
+
return possible_completions
|
260
|
+
end
|
261
|
+
end
|
262
|
+
|
263
|
+
|
264
|
+
#################################################################
|
265
|
+
### C O M M A N D S
|
266
|
+
#################################################################
|
267
|
+
|
268
|
+
### Show the completions hash
|
269
|
+
def show_completions_command
|
270
|
+
message "Completions:", @completions.inspect
|
271
|
+
end
|
272
|
+
|
273
|
+
|
274
|
+
### Show help text for the specified command, or a list of all available commands
|
275
|
+
### if none is specified.
|
276
|
+
def help_command( *args )
|
277
|
+
if args.empty?
|
278
|
+
$stderr.puts
|
279
|
+
message colorize( "Available commands", :bold, :white ),
|
280
|
+
*columnize(@commands)
|
281
|
+
else
|
282
|
+
cmd = args.shift.to_sym
|
283
|
+
if OPTION_PARSERS.key?( cmd )
|
284
|
+
oparser, _ = OPTION_PARSERS[ cmd ]
|
285
|
+
self.log.debug "Setting summary width to: %p" % [ @columns ]
|
286
|
+
oparser.summary_width = @columns
|
287
|
+
output = oparser.to_s.sub( /^(.*?)\n/ ) do |match|
|
288
|
+
colorize( :bold, :white ) { match }
|
289
|
+
end
|
290
|
+
|
291
|
+
$stderr.puts
|
292
|
+
message( output )
|
293
|
+
else
|
294
|
+
error_message( "No help for '#{cmd}'" )
|
295
|
+
end
|
90
296
|
end
|
91
297
|
end
|
92
298
|
|
93
299
|
|
94
300
|
### Quit the shell.
|
95
301
|
def quit_command( *args )
|
96
|
-
|
302
|
+
message "Okay, exiting."
|
97
303
|
@quit = true
|
98
304
|
end
|
99
305
|
|
100
306
|
|
101
|
-
LOG_LEVELS = {
|
102
|
-
'debug' => Logger::DEBUG,
|
103
|
-
'info' => Logger::INFO,
|
104
|
-
'warn' => Logger::WARN,
|
105
|
-
'error' => Logger::ERROR,
|
106
|
-
'fatal' => Logger::FATAL,
|
107
|
-
}.freeze
|
108
|
-
LOG_LEVEL_NAMES = LOG_LEVELS.invert.freeze
|
109
|
-
|
110
307
|
### Set the logging level (if invoked with an argument) or display the current
|
111
308
|
### level (with no argument).
|
112
309
|
def log_command( *args )
|
@@ -114,46 +311,135 @@ class Shell
|
|
114
311
|
if newlevel
|
115
312
|
if LOG_LEVELS.key?( newlevel )
|
116
313
|
Treequel.logger.level = LOG_LEVELS[ newlevel ]
|
117
|
-
|
314
|
+
message "Set log level to: %s" % [ newlevel ]
|
118
315
|
else
|
119
316
|
levelnames = LOG_LEVEL_NAMES.keys.sort.join(', ')
|
120
317
|
raise "Invalid log level %p: valid values are:\n %s" % [ newlevel, levelnames ]
|
121
318
|
end
|
122
319
|
else
|
123
|
-
|
320
|
+
message "Log level is currently: %s" %
|
124
321
|
[ LOG_LEVEL_NAMES[Treequel.logger.level] ]
|
125
322
|
end
|
126
323
|
end
|
127
324
|
|
128
325
|
|
129
|
-
###
|
130
|
-
def
|
131
|
-
|
132
|
-
@
|
326
|
+
### Display LDIF for the specified RDNs.
|
327
|
+
def cat_command( *args )
|
328
|
+
args.each do |rdn|
|
329
|
+
branch = @currbranch.get_child( rdn )
|
330
|
+
message( format_ldif(branch.to_ldif) )
|
331
|
+
end
|
133
332
|
end
|
134
333
|
|
135
334
|
|
136
|
-
### Display
|
137
|
-
def
|
335
|
+
### Display YAML for the specified RDNs.
|
336
|
+
def yaml_command( *args )
|
138
337
|
args.each do |rdn|
|
139
|
-
branch =
|
140
|
-
|
141
|
-
|
338
|
+
branch = @currbranch.get_child( rdn )
|
339
|
+
message( branch_as_yaml(branch) )
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
|
344
|
+
### List the children of the branch specified by the given +rdn+, or the current branch if none
|
345
|
+
### are specified.
|
346
|
+
def ls_command( options, *args )
|
347
|
+
targets = []
|
348
|
+
|
349
|
+
# No argument, just use the current branch
|
350
|
+
if args.empty?
|
351
|
+
targets << @currbranch
|
352
|
+
|
353
|
+
# Otherwise, list each one specified
|
354
|
+
else
|
355
|
+
args.each do |rdn|
|
356
|
+
if branch = @currbranch.get_child( rdn )
|
357
|
+
targets << branch
|
358
|
+
else
|
359
|
+
error_message( "cannot access #{rdn}: no such entry" )
|
360
|
+
end
|
142
361
|
end
|
362
|
+
end
|
363
|
+
|
364
|
+
# Fetch each branch's children, sort them, format them in columns, and highlight them
|
365
|
+
targets.each do |branch|
|
366
|
+
if options.longform
|
367
|
+
message self.make_longform_ls_output( branch, options )
|
368
|
+
else
|
369
|
+
message self.make_shortform_ls_output( branch, options )
|
370
|
+
end
|
371
|
+
end
|
372
|
+
end
|
373
|
+
set_options :ls do |oparser, options|
|
374
|
+
oparser.banner = "ls [OPTIONS] [DNs]"
|
143
375
|
|
144
|
-
|
376
|
+
oparser.on( "-l", "--long", FalseClass, "List in long format." ) do
|
377
|
+
options.longform = true
|
145
378
|
end
|
379
|
+
|
146
380
|
end
|
147
381
|
|
148
382
|
|
149
|
-
###
|
150
|
-
def
|
151
|
-
|
383
|
+
### Generate long-form output lines for the 'ls' command for the given +branch+.
|
384
|
+
def make_longform_ls_output( branch, options )
|
385
|
+
rows = []
|
386
|
+
children = branch.children
|
387
|
+
rows << colorize( :underscore, :cyan ) { "total %d" % [children.length] }
|
388
|
+
|
389
|
+
# Calcuate column widths
|
390
|
+
oclen = children.map do |branch|
|
391
|
+
branch.include_operational_attrs = true
|
392
|
+
branch[:structuralObjectClass].length
|
393
|
+
end.max
|
394
|
+
moddnlen = children.map do |branch|
|
395
|
+
branch[:modifiersName].length
|
396
|
+
end.max
|
397
|
+
|
398
|
+
children.
|
399
|
+
sort_by {|branch| branch.rdn.downcase }.
|
400
|
+
each do |branch|
|
401
|
+
# -rw-r--r-- 2 mgranger staff 979 2009-07-27 11:55 Rakefile.local
|
402
|
+
#
|
403
|
+
# modifiersName: cn=admin,dc=laika,dc=com
|
404
|
+
# hasSubordinates: TRUE
|
405
|
+
# modifyTimestamp: 20090520232650Z
|
406
|
+
# structuralObjectClass: organizationalUnit
|
407
|
+
rows << "%#{oclen}s %#{moddnlen}s %s %s%s" % [
|
408
|
+
branch[:structuralObjectClass],
|
409
|
+
branch[:modifiersName],
|
410
|
+
branch[:modifyTimestamp].strftime('%Y-%m-%d %H:%M'),
|
411
|
+
format_rdn( branch.rdn ),
|
412
|
+
branch[:hasSubordinates] ? '/' : ''
|
413
|
+
]
|
414
|
+
end
|
415
|
+
|
416
|
+
return rows
|
417
|
+
end
|
418
|
+
|
419
|
+
|
420
|
+
### Generate short-form 'ls' output for the given +branch+ and return it.
|
421
|
+
def make_shortform_ls_output( branch, options )
|
422
|
+
entries = branch.children.
|
423
|
+
collect {|b| b.rdn }.
|
424
|
+
sort_by {|rdn| rdn.downcase }
|
425
|
+
return columnize( entries ).
|
426
|
+
collect do |row|
|
427
|
+
row.gsub( /#{ATTRIBUTE_TYPE}=\s*\S+/ ) do |rdn|
|
428
|
+
format_rdn( rdn )
|
429
|
+
end
|
430
|
+
end
|
152
431
|
end
|
153
432
|
|
154
433
|
|
155
434
|
### Change the current working DN to +rdn+.
|
156
|
-
def cdn_command( rdn, *args )
|
435
|
+
def cdn_command( rdn=nil, *args )
|
436
|
+
if rdn.nil?
|
437
|
+
@currbranch = @dir.base
|
438
|
+
return
|
439
|
+
end
|
440
|
+
|
441
|
+
return self.parent_command if rdn == '..'
|
442
|
+
|
157
443
|
raise "invalid RDN %p" % [ rdn ] unless RELATIVE_DISTINGUISHED_NAME.match( rdn )
|
158
444
|
|
159
445
|
pairs = rdn.split( /\s*,\s*/ )
|
@@ -176,27 +462,131 @@ class Shell
|
|
176
462
|
|
177
463
|
|
178
464
|
### Edit the entry specified by +rdn+.
|
179
|
-
def edit_command(
|
180
|
-
|
465
|
+
#def edit_command( options, rdn )
|
466
|
+
# branch = @currbranch.get_child( rdn )
|
467
|
+
# entryhash = nil
|
468
|
+
#
|
469
|
+
# if options.newentry
|
470
|
+
# raise "#{branch.dn} already exists." if branch.exists?
|
471
|
+
# object_classes = prompt_for_multiple_values( "Entry objectClasses:" )
|
472
|
+
# entryhash = branch.valid_attributes_hash( *object_classes )
|
473
|
+
# newhash = edit_in_yaml( entryhash )
|
474
|
+
# args = object_classes + [newhash]
|
475
|
+
# branch.create( *args )
|
476
|
+
# else
|
477
|
+
# raise "#{branch.dn}: no such entry. Did you mean to create it with -n?" unless
|
478
|
+
# branch.exists?
|
479
|
+
# entryhash = branch.entry
|
480
|
+
# newhash = edit_in_yaml( entryhash )
|
481
|
+
# branch.merge( entryhash )
|
482
|
+
# end
|
483
|
+
#
|
484
|
+
# message "Saved #{rdn}."
|
485
|
+
#end
|
486
|
+
#set_options :edit do |oparser, options|
|
487
|
+
# oparser.banner = "edit [OPTIONS] DN"
|
488
|
+
#
|
489
|
+
# oparser.on( "-n", "--new", FalseClass,
|
490
|
+
# "Create a new entry instead of editing an existing one." ) do
|
491
|
+
# options.newentry = true
|
492
|
+
# end
|
493
|
+
#
|
494
|
+
#end
|
495
|
+
|
496
|
+
|
497
|
+
### Convert the given +patterns+ to branchsets relative to the current branch and return
|
498
|
+
### them. This is used to map shell arguments like 'cn=*', 'Hosts', 'cn=dav*' into
|
499
|
+
### branchsets that will find matching entries.
|
500
|
+
def convert_to_branchsets( *patterns )
|
501
|
+
self.log.debug "Turning %d patterns into branchsets." % [ patterns.length ]
|
502
|
+
return patterns.collect do |pat|
|
503
|
+
key, val = pat.split( /\s*=\s*/, 2 )
|
504
|
+
self.log.debug " making a filter out of %p => %p" % [ key, val ]
|
505
|
+
@currbranch.filter( key => val )
|
506
|
+
end
|
507
|
+
end
|
508
|
+
|
509
|
+
|
510
|
+
### Remove the entry specified by +rdn+.
|
511
|
+
def rm_command( options, *rdns )
|
512
|
+
branchsets = self.convert_to_branchsets( *rdns )
|
513
|
+
coll = Treequel::BranchCollection.new( *branchsets )
|
514
|
+
|
515
|
+
branches = coll.all
|
516
|
+
|
517
|
+
msg = "About to delete the following entries:\n" +
|
518
|
+
branches.collect {|br| " #{br.dn}" }.join("\n")
|
519
|
+
|
520
|
+
ask_for_confirmation( msg ) do
|
521
|
+
branches.each do |branch|
|
522
|
+
branch.delete
|
523
|
+
message "Deleted #{branch.dn}."
|
524
|
+
end
|
525
|
+
end
|
526
|
+
end
|
527
|
+
set_options :rm do |oparser, options|
|
528
|
+
oparser.banner = "rm [DNs]"
|
529
|
+
end
|
530
|
+
|
531
|
+
|
532
|
+
### Find entries that match the given filter_clauses.
|
533
|
+
def grep_command( options, *filter_clauses )
|
534
|
+
branchset = filter_clauses.inject( @currbranch ) do |branch, clause|
|
535
|
+
branch.filter( clause )
|
536
|
+
end
|
181
537
|
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
|
538
|
+
message "Searching for entries that match '#{branchset.to_s}'"
|
539
|
+
|
540
|
+
entries = branchset.all
|
541
|
+
output = columnize( entries ).
|
542
|
+
collect do |row|
|
543
|
+
row.gsub( /#{ATTRIBUTE_TYPE}=\s*\S+/ ) do |rdn|
|
544
|
+
format_rdn( rdn )
|
545
|
+
end
|
546
|
+
end
|
547
|
+
message( output )
|
548
|
+
end
|
549
|
+
set_options :grep do |oparser, options|
|
550
|
+
oparser.banner = "grep [OPTIONS] FILTER"
|
551
|
+
end
|
552
|
+
|
553
|
+
|
554
|
+
### Bind as a user.
|
555
|
+
def bind_command( options, *args )
|
556
|
+
binddn = (args.first || prompt( "Bind DN/UID" )) or
|
557
|
+
raise "Cancelled."
|
558
|
+
password = prompt_for_password()
|
559
|
+
|
560
|
+
# Try to turn a non-DN into a DN
|
561
|
+
user = nil
|
562
|
+
if binddn.index( '=' )
|
563
|
+
user = Treequel::Branch.new( @dir, binddn )
|
564
|
+
else
|
565
|
+
user = @dir.filter( :uid => binddn ).first
|
566
|
+
end
|
567
|
+
raise "No user found for %p" % [ binddn ] unless user.exists?
|
568
|
+
|
569
|
+
@dir.bind( user, password )
|
570
|
+
message "Bound as #{user}"
|
571
|
+
end
|
572
|
+
set_options :bind do |oparser, options|
|
573
|
+
oparser.banner = "bind [BIND_DN or UID]"
|
574
|
+
oparser.separator "If you don't specify a BIND_DN, you will be prompted for it."
|
186
575
|
end
|
187
576
|
|
188
577
|
|
189
578
|
### Handle a command from the user that doesn't exist.
|
190
|
-
def
|
579
|
+
def handle_missing_cmd( *args )
|
191
580
|
command = args.shift || '(testing?)'
|
192
|
-
|
193
|
-
|
581
|
+
message "Unknown command %p" % [ command ]
|
582
|
+
message "Known commands: ", ' ' + @commands.join(', ')
|
194
583
|
end
|
195
584
|
|
196
585
|
|
197
586
|
### Find methods that implement commands and return them in a sorted Array.
|
198
587
|
def find_commands
|
199
588
|
return self.methods.
|
589
|
+
collect {|mname| mname.to_s }.
|
200
590
|
grep( /^(\w+)_command$/ ).
|
201
591
|
collect {|mname| mname[/^(\w+)_command$/, 1] }.
|
202
592
|
sort
|
@@ -207,6 +597,51 @@ class Shell
|
|
207
597
|
private
|
208
598
|
#######
|
209
599
|
|
600
|
+
### Dump the specified +object+ to a file as YAML, invoke an editor on it, then undump the
|
601
|
+
### result. If the file has changed, return the updated object, else returns +nil+.
|
602
|
+
def edit_in_yaml( object )
|
603
|
+
yaml = branch_as_yaml( object )
|
604
|
+
filename = Digest::SHA1.hexdigest( yaml )
|
605
|
+
tempfile = Tempfile.new( filename )
|
606
|
+
|
607
|
+
message "Object as YAML is: ", yaml
|
608
|
+
tempfile.print( yaml )
|
609
|
+
tempfile.close
|
610
|
+
|
611
|
+
new_yaml = edit( tempfile.path )
|
612
|
+
|
613
|
+
if new_yaml == yaml
|
614
|
+
message "Unchanged."
|
615
|
+
return nil
|
616
|
+
else
|
617
|
+
return YAML.load( new_yaml )
|
618
|
+
end
|
619
|
+
end
|
620
|
+
|
621
|
+
|
622
|
+
### Return the specified Treequel::Branch object as YAML. If +include_operational+ is true,
|
623
|
+
### include the entry's operational attributes.
|
624
|
+
def branch_as_yaml( object, include_operational=false )
|
625
|
+
object.include_operational_attrs = include_operational
|
626
|
+
|
627
|
+
# Make sure the displayed entry has the MUST attributes
|
628
|
+
entryhash = stringify_keys( object.must_attributes_hash )
|
629
|
+
entryhash.merge!( object.entry )
|
630
|
+
dn = entryhash.delete( 'dn' )
|
631
|
+
|
632
|
+
yaml = entryhash.to_yaml
|
633
|
+
yaml[ 5, 0 ] = "# #{dn}\n"
|
634
|
+
|
635
|
+
# Make comments out of MAY attributes that are unset
|
636
|
+
mayhash = stringify_keys( object.may_attributes_hash )
|
637
|
+
self.log.debug "MAY hash is: %p" % [ mayhash ]
|
638
|
+
mayhash.delete_if {|attrname,val| entryhash.key?(attrname) }
|
639
|
+
yaml << mayhash.to_yaml[5..-1].gsub( /\n\n/, "\n" ).gsub( /^/, '# ' )
|
640
|
+
|
641
|
+
return yaml
|
642
|
+
end
|
643
|
+
|
644
|
+
|
210
645
|
### Create a command table that maps command abbreviations to the Method object that
|
211
646
|
### implements it.
|
212
647
|
def make_command_table( commands )
|
@@ -219,11 +654,327 @@ class Shell
|
|
219
654
|
return table
|
220
655
|
end
|
221
656
|
|
222
|
-
|
657
|
+
|
658
|
+
### Return the specified args as a string, quoting any that have a space.
|
659
|
+
def quotelist( *args )
|
660
|
+
return args.flatten.collect {|part| part =~ /\s/ ? part.inspect : part}
|
661
|
+
end
|
223
662
|
|
224
663
|
|
225
|
-
if
|
226
|
-
|
227
|
-
|
228
|
-
|
664
|
+
### Run the specified command +cmd+ with system(), failing if the execution
|
665
|
+
### fails.
|
666
|
+
def run_command( *cmd )
|
667
|
+
cmd.flatten!
|
668
|
+
|
669
|
+
if cmd.length > 1
|
670
|
+
self.log.debug( quotelist(*cmd) )
|
671
|
+
else
|
672
|
+
self.log.debug( cmd )
|
673
|
+
end
|
674
|
+
|
675
|
+
if $dryrun
|
676
|
+
self.log.error "(dry run mode)"
|
677
|
+
else
|
678
|
+
system( *cmd )
|
679
|
+
unless $?.success?
|
680
|
+
raise "Command failed: [%s]" % [cmd.join(' ')]
|
681
|
+
end
|
682
|
+
end
|
683
|
+
end
|
684
|
+
|
685
|
+
|
686
|
+
### Run the given +cmd+ with the specified +args+ without interpolation by the shell and
|
687
|
+
### return anything written to its STDOUT.
|
688
|
+
def read_command_output( cmd, *args )
|
689
|
+
self.log.debug "Reading output from: %s" % [ cmd, quotelist(cmd, *args) ]
|
690
|
+
output = IO.read( '|-' ) or exec cmd, *args
|
691
|
+
return output
|
692
|
+
end
|
693
|
+
|
694
|
+
|
695
|
+
### Run a subordinate Rake process with the same options and the specified +targets+.
|
696
|
+
def rake( *targets )
|
697
|
+
opts = ARGV.select {|arg| arg[0,1] == '-' }
|
698
|
+
args = opts + targets.map {|t| t.to_s }
|
699
|
+
run 'rake', '-N', *args
|
700
|
+
end
|
701
|
+
|
702
|
+
|
703
|
+
### Open a pipe to a process running the given +cmd+ and call the given block with it.
|
704
|
+
def pipeto( *cmd )
|
705
|
+
$DEBUG = true
|
706
|
+
|
707
|
+
cmd.flatten!
|
708
|
+
self.log.info( "Opening a pipe to: ", cmd.collect {|part| part =~ /\s/ ? part.inspect : part} )
|
709
|
+
if $dryrun
|
710
|
+
message "(dry run mode)"
|
711
|
+
else
|
712
|
+
open( '|-', 'w+' ) do |io|
|
713
|
+
|
714
|
+
# Parent
|
715
|
+
if io
|
716
|
+
yield( io )
|
717
|
+
|
718
|
+
# Child
|
719
|
+
else
|
720
|
+
exec( *cmd )
|
721
|
+
raise "Command failed: [%s]" % [cmd.join(' ')]
|
722
|
+
end
|
723
|
+
end
|
724
|
+
end
|
725
|
+
end
|
726
|
+
|
727
|
+
|
728
|
+
### Return the fully-qualified path to the specified +program+ in the PATH.
|
729
|
+
def which( program )
|
730
|
+
ENV['PATH'].split(/:/).
|
731
|
+
collect {|dir| Pathname.new(dir) + program }.
|
732
|
+
find {|path| path.exist? && path.executable? }
|
733
|
+
end
|
734
|
+
|
735
|
+
|
736
|
+
### Output the specified message +parts+.
|
737
|
+
def message( *parts )
|
738
|
+
$stderr.puts( *parts )
|
739
|
+
end
|
740
|
+
|
741
|
+
|
742
|
+
### Output the specified <tt>msg</tt> as an ANSI-colored error message
|
743
|
+
### (white on red).
|
744
|
+
def error_message( msg, details='' )
|
745
|
+
$stderr.puts colorize( 'bold', 'white', 'on_red' ) { msg } + ' ' + details
|
746
|
+
end
|
747
|
+
alias :error :error_message
|
748
|
+
|
749
|
+
|
750
|
+
### Highlight and embed a prompt control character in the given +string+ and return it.
|
751
|
+
def make_prompt_string( string )
|
752
|
+
return CLEAR_CURRENT_LINE + colorize( 'bold', 'yellow' ) { string + ' ' }
|
753
|
+
end
|
754
|
+
|
755
|
+
|
756
|
+
### Output the specified <tt>prompt_string</tt> as a prompt (in green) and
|
757
|
+
### return the user's input with leading and trailing spaces removed. If a
|
758
|
+
### test is provided, the prompt will repeat until the test returns true.
|
759
|
+
### An optional failure message can also be passed in.
|
760
|
+
def prompt( prompt_string, failure_msg="Try again." ) # :yields: response
|
761
|
+
prompt_string.chomp!
|
762
|
+
prompt_string << ":" unless /\W$/.match( prompt_string )
|
763
|
+
response = nil
|
764
|
+
|
765
|
+
begin
|
766
|
+
prompt = make_prompt_string( prompt_string )
|
767
|
+
response = readline( prompt ) || ''
|
768
|
+
response.strip!
|
769
|
+
if block_given? && ! yield( response )
|
770
|
+
error_message( failure_msg + "\n\n" )
|
771
|
+
response = nil
|
772
|
+
end
|
773
|
+
end while response.nil?
|
774
|
+
|
775
|
+
return response
|
776
|
+
end
|
777
|
+
|
778
|
+
|
779
|
+
### Prompt the user with the given <tt>prompt_string</tt> via #prompt,
|
780
|
+
### substituting the given <tt>default</tt> if the user doesn't input
|
781
|
+
### anything. If a test is provided, the prompt will repeat until the test
|
782
|
+
### returns true. An optional failure message can also be passed in.
|
783
|
+
def prompt_with_default( prompt_string, default, failure_msg="Try again." )
|
784
|
+
response = nil
|
785
|
+
|
786
|
+
begin
|
787
|
+
default ||= '~'
|
788
|
+
response = prompt( "%s [%s]" % [ prompt_string, default ] )
|
789
|
+
response = default.to_s if !response.nil? && response.empty?
|
790
|
+
|
791
|
+
self.log.debug "Validating response %p" % [ response ]
|
792
|
+
|
793
|
+
# the block is a validator. We need to make sure that the user didn't
|
794
|
+
# enter '~', because if they did, it's nil and we should move on. If
|
795
|
+
# they didn't, then call the block.
|
796
|
+
if block_given? && response != '~' && ! yield( response )
|
797
|
+
error_message( failure_msg + "\n\n" )
|
798
|
+
response = nil
|
799
|
+
end
|
800
|
+
end while response.nil?
|
801
|
+
|
802
|
+
return nil if response == '~'
|
803
|
+
return response
|
804
|
+
end
|
805
|
+
|
806
|
+
|
807
|
+
### Prompt for an array of values
|
808
|
+
def prompt_for_multiple_values( label, default=nil )
|
809
|
+
message( MULTILINE_PROMPT % [label] )
|
810
|
+
if default
|
811
|
+
message "Enter a single blank line to keep the default:\n %p" % [ default ]
|
812
|
+
end
|
813
|
+
|
814
|
+
results = []
|
815
|
+
result = nil
|
816
|
+
|
817
|
+
begin
|
818
|
+
result = readline( make_prompt_string("> ") )
|
819
|
+
if result.nil? || result.empty?
|
820
|
+
results << default if default && results.empty?
|
821
|
+
else
|
822
|
+
results << result
|
823
|
+
end
|
824
|
+
end until result.nil? || result.empty?
|
825
|
+
|
826
|
+
return results.flatten
|
827
|
+
end
|
828
|
+
|
829
|
+
|
830
|
+
### Turn echo and masking of input on/off.
|
831
|
+
def noecho( masked=false )
|
832
|
+
rval = nil
|
833
|
+
term = Termios.getattr( $stdin )
|
834
|
+
|
835
|
+
begin
|
836
|
+
newt = term.dup
|
837
|
+
newt.c_lflag &= ~Termios::ECHO
|
838
|
+
newt.c_lflag &= ~Termios::ICANON if masked
|
839
|
+
|
840
|
+
Termios.tcsetattr( $stdin, Termios::TCSANOW, newt )
|
841
|
+
|
842
|
+
rval = yield
|
843
|
+
ensure
|
844
|
+
Termios.tcsetattr( $stdin, Termios::TCSANOW, term )
|
845
|
+
end
|
846
|
+
|
847
|
+
return rval
|
848
|
+
end
|
849
|
+
|
850
|
+
|
851
|
+
### Prompt the user for her password, turning off echo if the 'termios' module is
|
852
|
+
### available.
|
853
|
+
def prompt_for_password( prompt="Password: " )
|
854
|
+
rval = nil
|
855
|
+
noecho( true ) do
|
856
|
+
$stderr.print( prompt )
|
857
|
+
rval = ($stdin.gets || '').chomp
|
858
|
+
end
|
859
|
+
$stderr.puts
|
860
|
+
return rval
|
861
|
+
end
|
862
|
+
|
863
|
+
|
864
|
+
### Display a description of a potentially-dangerous task, and prompt
|
865
|
+
### for confirmation. If the user answers with anything that begins
|
866
|
+
### with 'y', yield to the block. If +abort_on_decline+ is +true+,
|
867
|
+
### any non-'y' answer will fail with an error message.
|
868
|
+
def ask_for_confirmation( description, abort_on_decline=true )
|
869
|
+
puts description
|
870
|
+
|
871
|
+
answer = prompt_with_default( "Continue?", 'n' ) do |input|
|
872
|
+
input =~ /^[yn]/i
|
873
|
+
end
|
874
|
+
|
875
|
+
if answer =~ /^y/i
|
876
|
+
return yield
|
877
|
+
elsif abort_on_decline
|
878
|
+
error "Aborted."
|
879
|
+
fail
|
880
|
+
end
|
881
|
+
|
882
|
+
return false
|
883
|
+
end
|
884
|
+
alias :prompt_for_confirmation :ask_for_confirmation
|
885
|
+
|
886
|
+
|
887
|
+
### Search line-by-line in the specified +file+ for the given +regexp+, returning the
|
888
|
+
### first match, or nil if no match was found. If the +regexp+ has any capture groups,
|
889
|
+
### those will be returned in an Array, else the whole matching line is returned.
|
890
|
+
def find_pattern_in_file( regexp, file )
|
891
|
+
rval = nil
|
892
|
+
|
893
|
+
File.open( file, 'r' ).each do |line|
|
894
|
+
if (( match = regexp.match(line) ))
|
895
|
+
rval = match.captures.empty? ? match[0] : match.captures
|
896
|
+
break
|
897
|
+
end
|
898
|
+
end
|
899
|
+
|
900
|
+
return rval
|
901
|
+
end
|
902
|
+
|
903
|
+
|
904
|
+
### Search line-by-line in the output of the specified +cmd+ for the given +regexp+,
|
905
|
+
### returning the first match, or nil if no match was found. If the +regexp+ has any
|
906
|
+
### capture groups, those will be returned in an Array, else the whole matching line
|
907
|
+
### is returned.
|
908
|
+
def find_pattern_in_pipe( regexp, *cmd )
|
909
|
+
output = []
|
910
|
+
|
911
|
+
self.log.info( cmd.collect {|part| part =~ /\s/ ? part.inspect : part} )
|
912
|
+
Open3.popen3( *cmd ) do |stdin, stdout, stderr|
|
913
|
+
stdin.close
|
914
|
+
|
915
|
+
output << stdout.gets until stdout.eof?
|
916
|
+
output << stderr.gets until stderr.eof?
|
917
|
+
end
|
918
|
+
|
919
|
+
result = output.find { |line| regexp.match(line) }
|
920
|
+
return $1 || result
|
921
|
+
end
|
922
|
+
|
923
|
+
|
924
|
+
### Invoke the user's editor on the given +filename+ and return the exit code
|
925
|
+
### from doing so.
|
926
|
+
def edit( filename )
|
927
|
+
editor = ENV['EDITOR'] || ENV['VISUAL'] || DEFAULT_EDITOR
|
928
|
+
system editor, filename.to_s
|
929
|
+
unless $?.success? || editor =~ /vim/i
|
930
|
+
raise "Editor exited with an error status (%d)" % [ $?.exitstatus ]
|
931
|
+
end
|
932
|
+
return File.read( filename )
|
933
|
+
end
|
934
|
+
|
935
|
+
|
936
|
+
### Make an easily-comparable version vector out of +ver+ and return it.
|
937
|
+
def vvec( ver )
|
938
|
+
return ver.split('.').collect {|char| char.to_i }.pack('N*')
|
939
|
+
end
|
940
|
+
|
941
|
+
|
942
|
+
### Return an ANSI-colored version of the given +rdn+ string.
|
943
|
+
def format_rdn( rdn )
|
944
|
+
rdn.split( /,/ ).collect do |rdn|
|
945
|
+
key, val = rdn.split( /\s*=\s*/, 2 )
|
946
|
+
colorize( :white ) { key } +
|
947
|
+
colorize( :bold, :black ) { '=' } +
|
948
|
+
colorize( :bold, :white ) { val }
|
949
|
+
end.join( colorize(',', :green) )
|
950
|
+
end
|
951
|
+
|
952
|
+
|
953
|
+
### Highlight LDIF and return it.
|
954
|
+
def format_ldif( ldif )
|
955
|
+
return ldif.gsub( /^(\S[^:]*)(::?)\s*(.*)$/ ) do
|
956
|
+
key, sep, val = $1, $2, $3
|
957
|
+
case sep
|
958
|
+
when '::'
|
959
|
+
colorize( :cyan ) { key } + ':: ' + colorize( :dark, :white ) { val }
|
960
|
+
when ':'
|
961
|
+
colorize( :bold, :cyan ) { key } + ': ' + colorize( :dark, :white ) { val }
|
962
|
+
else
|
963
|
+
key + sep + ' ' + val
|
964
|
+
end
|
965
|
+
end
|
966
|
+
end
|
967
|
+
|
968
|
+
|
969
|
+
### Return the specified +entries+ as an Array of span-sorted columns fit to the
|
970
|
+
### current terminal width.
|
971
|
+
def columnize( *entries )
|
972
|
+
return Columnize.columnize( entries.flatten, @columns, ' ' )
|
973
|
+
end
|
974
|
+
|
975
|
+
end # class Treequel::Shell
|
976
|
+
|
977
|
+
|
978
|
+
ldapuri = URI( ARGV.shift || 'ldap://localhost' )
|
979
|
+
Treequel::Shell.new( ldapuri ).run
|
229
980
|
|