bopeep 0.1.6
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.
- checksums.yaml +7 -0
- data/README.md +1 -0
- data/bin/bopeep +7 -0
- data/bin/console-playground +123 -0
- data/lib/bopeep.rb +2294 -0
- metadata +200 -0
data/lib/bopeep.rb
ADDED
@@ -0,0 +1,2294 @@
|
|
1
|
+
require "set"
|
2
|
+
require "uri"
|
3
|
+
require "optparse"
|
4
|
+
require "pathname"
|
5
|
+
require "forwardable"
|
6
|
+
require "tempfile"
|
7
|
+
require "digest/sha1"
|
8
|
+
|
9
|
+
require "net/ssh"
|
10
|
+
require "net/scp"
|
11
|
+
|
12
|
+
unless Hash.method_defined?(:transform_keys)
|
13
|
+
class Hash
|
14
|
+
def transform_keys
|
15
|
+
if block_given?
|
16
|
+
map { |key, value| [yield(key), value] }.to_h
|
17
|
+
else
|
18
|
+
enum_for(:transform_keys)
|
19
|
+
end
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
23
|
+
|
24
|
+
unless Exception.method_defined?(:full_message)
|
25
|
+
class Exception
|
26
|
+
def full_message(highlight: true, order: :bottom)
|
27
|
+
trace = backtrace || [caller[0]]
|
28
|
+
|
29
|
+
trace_head = trace[0]
|
30
|
+
trace_tail = trace[1..-1] || []
|
31
|
+
|
32
|
+
output = "#{trace_head}: \e[1m#{message} (\e[1;4m#{self.class}\e[m\e[1m)\e[m"
|
33
|
+
|
34
|
+
case order.to_sym
|
35
|
+
when :top
|
36
|
+
unless trace_tail.empty?
|
37
|
+
output << "\n\t from #{trace_tail.join("\n\t from ")}"
|
38
|
+
end
|
39
|
+
when :bottom
|
40
|
+
trace_tail.each_with_index do |line, index|
|
41
|
+
output.prepend "\t#{index + 1}: from #{line}\n"
|
42
|
+
end
|
43
|
+
|
44
|
+
output.prepend "\e[1mTraceback\e[m (most recent call last):\n"
|
45
|
+
end
|
46
|
+
|
47
|
+
unless highlight
|
48
|
+
output.gsub!(/\e\[(\d+;)+m/, "")
|
49
|
+
end
|
50
|
+
|
51
|
+
output
|
52
|
+
end
|
53
|
+
end
|
54
|
+
end
|
55
|
+
|
56
|
+
module BoPeep
|
57
|
+
class InvalidHostDataError < StandardError
|
58
|
+
def initialize(host_data)
|
59
|
+
@host_data = host_data
|
60
|
+
end
|
61
|
+
|
62
|
+
def message
|
63
|
+
"could not instantiate a host from `#{@host_data.inspect}'"
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
class Console
|
68
|
+
class << self
|
69
|
+
attr_accessor :hostname_width
|
70
|
+
end
|
71
|
+
|
72
|
+
def self.SGR(text)
|
73
|
+
SelectGraphicRendition::Renderer.new(text)
|
74
|
+
end
|
75
|
+
|
76
|
+
def initialize
|
77
|
+
@io = $stderr
|
78
|
+
@history = History.new
|
79
|
+
@cursor_position = CursorPosition.new
|
80
|
+
@io_mutex = Mutex.new
|
81
|
+
@history_mutex = Mutex.new
|
82
|
+
end
|
83
|
+
|
84
|
+
def redirecting_stdout_and_stderr
|
85
|
+
$stdout = IOProxy.new($stdout, self)
|
86
|
+
$stderr = IOProxy.new($stderr, self)
|
87
|
+
|
88
|
+
yield
|
89
|
+
ensure
|
90
|
+
$stdout = $stdout.__getobj__
|
91
|
+
$stderr = $stderr.__getobj__
|
92
|
+
end
|
93
|
+
|
94
|
+
def puts(text_or_content = nil)
|
95
|
+
content = print_content text_or_content
|
96
|
+
|
97
|
+
unless text_or_content.to_s.end_with?("\n")
|
98
|
+
print_content "\n"
|
99
|
+
end
|
100
|
+
|
101
|
+
content
|
102
|
+
end
|
103
|
+
|
104
|
+
def print(text_or_content = nil)
|
105
|
+
print_content(text_or_content)
|
106
|
+
end
|
107
|
+
|
108
|
+
def print_content(text_or_content)
|
109
|
+
content = case text_or_content
|
110
|
+
when Content, HostStatus
|
111
|
+
text_or_content
|
112
|
+
else
|
113
|
+
Content.new(text_or_content, self)
|
114
|
+
end
|
115
|
+
|
116
|
+
@io_mutex.synchronize do
|
117
|
+
@io.print content.to_s
|
118
|
+
|
119
|
+
append_to_history(content)
|
120
|
+
|
121
|
+
content
|
122
|
+
end
|
123
|
+
end
|
124
|
+
|
125
|
+
def append_to_history(content)
|
126
|
+
@history_mutex.synchronize do
|
127
|
+
@history.append(content, at: @cursor_position)
|
128
|
+
@cursor_position.adjust_to(content)
|
129
|
+
end
|
130
|
+
end
|
131
|
+
|
132
|
+
# For CSI sequences, see:
|
133
|
+
# https://en.wikipedia.org/wiki/ANSI_escape_code#Terminal_output_sequences
|
134
|
+
def reprint_content(content)
|
135
|
+
@io_mutex.lock
|
136
|
+
|
137
|
+
history_entry = @history.find_entry_for(content)
|
138
|
+
|
139
|
+
rows_from_cursor_row_to_content_start = @cursor_position.row - history_entry.row_number
|
140
|
+
|
141
|
+
# starting from the current line, clear the line and move up a line
|
142
|
+
#
|
143
|
+
# this will bring us to the line the content we're reprinting, clearing all lines beneath
|
144
|
+
# it
|
145
|
+
rows_from_cursor_row_to_content_start.times do
|
146
|
+
# clear the current line
|
147
|
+
@io.print "\e[2K"
|
148
|
+
|
149
|
+
# move up a line
|
150
|
+
@io.print "\e[1A"
|
151
|
+
end
|
152
|
+
|
153
|
+
# go to the column of the content
|
154
|
+
@io.print "\e[#{history_entry.column_number}G"
|
155
|
+
|
156
|
+
# clear from the starting column of the content to the end of the line
|
157
|
+
@io.print "\e[0K"
|
158
|
+
|
159
|
+
# re-print the content from its original location
|
160
|
+
@io.print content.to_s
|
161
|
+
|
162
|
+
# initialize how much we'll be adjusting the column number by
|
163
|
+
column_adjustment = history_entry.end_column
|
164
|
+
|
165
|
+
current_entry = history_entry
|
166
|
+
|
167
|
+
until current_entry.next_entry.nil?
|
168
|
+
next_entry = current_entry.next_entry
|
169
|
+
|
170
|
+
# we only update the column of subsequent entries if they were on the same line as the
|
171
|
+
# entry being reprinted.
|
172
|
+
if next_entry.row_number == history_entry.end_row
|
173
|
+
next_entry.column_number = column_adjustment
|
174
|
+
column_adjustment += next_entry.last_line_length
|
175
|
+
end
|
176
|
+
|
177
|
+
# update the next entry's row number by how many rows the updated entry grew or shrank
|
178
|
+
next_entry.row_number += history_entry.newline_count_diff
|
179
|
+
|
180
|
+
# print the content
|
181
|
+
@io.print next_entry.to_s
|
182
|
+
|
183
|
+
# get the next entry to repeat
|
184
|
+
current_entry = next_entry
|
185
|
+
end
|
186
|
+
|
187
|
+
# Update the cursor position by how many row's the new content has changed and the new
|
188
|
+
# end column of the last entry in the history
|
189
|
+
@cursor_position.column = current_entry.end_column
|
190
|
+
@cursor_position.row += history_entry.newline_count_diff
|
191
|
+
|
192
|
+
# reset `newline_count` and `last_line_length` to what they now are in `@content`
|
193
|
+
history_entry.sync!
|
194
|
+
|
195
|
+
@io_mutex.unlock
|
196
|
+
end
|
197
|
+
|
198
|
+
class IOProxy < SimpleDelegator
|
199
|
+
def initialize(io, console)
|
200
|
+
if io.is_a? IOProxy
|
201
|
+
raise ArgumentError, "cannot nest `IOProxy' instances"
|
202
|
+
end
|
203
|
+
|
204
|
+
@io = io
|
205
|
+
@console = console
|
206
|
+
__setobj__ @io
|
207
|
+
end
|
208
|
+
|
209
|
+
def print(*texts)
|
210
|
+
texts.each do |text|
|
211
|
+
@io.print text
|
212
|
+
|
213
|
+
append_to_console_history text
|
214
|
+
end
|
215
|
+
|
216
|
+
nil
|
217
|
+
end
|
218
|
+
|
219
|
+
def puts(*texts)
|
220
|
+
texts.each do |text|
|
221
|
+
@io.puts text
|
222
|
+
|
223
|
+
append_to_console_history text
|
224
|
+
|
225
|
+
unless text.to_s.end_with?("\n")
|
226
|
+
append_to_console_history "\n"
|
227
|
+
end
|
228
|
+
end
|
229
|
+
|
230
|
+
nil
|
231
|
+
end
|
232
|
+
|
233
|
+
def write(*texts)
|
234
|
+
texts.inject(0) do |count, text|
|
235
|
+
@io.print text
|
236
|
+
|
237
|
+
append_to_console_history(text)
|
238
|
+
|
239
|
+
count + text.to_s.length
|
240
|
+
end
|
241
|
+
end
|
242
|
+
|
243
|
+
private
|
244
|
+
|
245
|
+
def append_to_console_history(text)
|
246
|
+
content = Content.new(text, @console)
|
247
|
+
@console.append_to_history(content)
|
248
|
+
end
|
249
|
+
end
|
250
|
+
|
251
|
+
class CursorPosition
|
252
|
+
attr_accessor :column
|
253
|
+
attr_accessor :row
|
254
|
+
|
255
|
+
def initialize
|
256
|
+
@row = 1
|
257
|
+
@column = 1
|
258
|
+
end
|
259
|
+
|
260
|
+
def adjust_to(content)
|
261
|
+
last_line_length = content.last_line_length
|
262
|
+
newline_count = content.newline_count
|
263
|
+
|
264
|
+
if newline_count > 0
|
265
|
+
@column = last_line_length + 1
|
266
|
+
else
|
267
|
+
@column += last_line_length
|
268
|
+
end
|
269
|
+
|
270
|
+
@row += newline_count
|
271
|
+
end
|
272
|
+
|
273
|
+
def inspect
|
274
|
+
%{#<#{self.class.name} row=#{row} column=#{column}>}
|
275
|
+
end
|
276
|
+
end
|
277
|
+
|
278
|
+
class History
|
279
|
+
def initialize
|
280
|
+
@head = FirstEntry.new
|
281
|
+
end
|
282
|
+
|
283
|
+
def append(content, at: cursor_position)
|
284
|
+
entry = Entry.new(content, at)
|
285
|
+
|
286
|
+
entry.previous_entry = @head
|
287
|
+
@head.next_entry = entry
|
288
|
+
|
289
|
+
@head = entry
|
290
|
+
end
|
291
|
+
|
292
|
+
def find_entry_for(content)
|
293
|
+
current_entry = @head
|
294
|
+
|
295
|
+
until current_entry.previous_entry.nil? || current_entry.content == content
|
296
|
+
current_entry = current_entry.previous_entry
|
297
|
+
end
|
298
|
+
|
299
|
+
current_entry
|
300
|
+
end
|
301
|
+
|
302
|
+
def inspect
|
303
|
+
%{#<#{self.class.name} head=#{@head}>}
|
304
|
+
end
|
305
|
+
|
306
|
+
class FirstEntry
|
307
|
+
attr_reader :content
|
308
|
+
attr_accessor :next_entry
|
309
|
+
attr_reader :previous_entry
|
310
|
+
|
311
|
+
def column_number
|
312
|
+
1
|
313
|
+
end
|
314
|
+
|
315
|
+
def row_number
|
316
|
+
1
|
317
|
+
end
|
318
|
+
|
319
|
+
def newline_count
|
320
|
+
0
|
321
|
+
end
|
322
|
+
|
323
|
+
def last_line_length
|
324
|
+
0
|
325
|
+
end
|
326
|
+
|
327
|
+
def to_s
|
328
|
+
""
|
329
|
+
end
|
330
|
+
|
331
|
+
def inspect
|
332
|
+
%{#<#{self.class.name}>}
|
333
|
+
end
|
334
|
+
end
|
335
|
+
|
336
|
+
class Entry
|
337
|
+
attr_accessor :column_number
|
338
|
+
attr_reader :content
|
339
|
+
attr_reader :last_line_length
|
340
|
+
attr_accessor :next_entry
|
341
|
+
attr_reader :newline_count
|
342
|
+
attr_accessor :previous_entry
|
343
|
+
attr_accessor :row_number
|
344
|
+
|
345
|
+
def initialize(content, cursor_position)
|
346
|
+
@content = content
|
347
|
+
|
348
|
+
@row_number = cursor_position.row
|
349
|
+
@column_number = cursor_position.column
|
350
|
+
|
351
|
+
@text = @content.to_s
|
352
|
+
@newline_count = @content.newline_count
|
353
|
+
@last_line_length = @content.last_line_length
|
354
|
+
end
|
355
|
+
|
356
|
+
def sync!
|
357
|
+
@text = @content.to_s
|
358
|
+
@last_line_length = @content.last_line_length
|
359
|
+
end
|
360
|
+
|
361
|
+
def newline_count_diff
|
362
|
+
@content.newline_count - @newline_count
|
363
|
+
end
|
364
|
+
|
365
|
+
def end_row
|
366
|
+
@row_number + newline_count
|
367
|
+
end
|
368
|
+
|
369
|
+
def end_column
|
370
|
+
value = 1 + @content.last_line_length
|
371
|
+
|
372
|
+
if newline_count.zero?
|
373
|
+
value += @column_number
|
374
|
+
end
|
375
|
+
|
376
|
+
value
|
377
|
+
end
|
378
|
+
|
379
|
+
def to_s
|
380
|
+
@text.dup
|
381
|
+
end
|
382
|
+
|
383
|
+
def inspect
|
384
|
+
%{#<#{self.class.name} row_number=#{row_number} column_number=#{column_number} content=#{content.inspect}>}
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
class Content
|
390
|
+
def initialize(text, console)
|
391
|
+
@text = text.to_s.freeze
|
392
|
+
@console = console
|
393
|
+
end
|
394
|
+
|
395
|
+
def text=(value)
|
396
|
+
@text = value.to_s.freeze
|
397
|
+
@console.reprint_content(self)
|
398
|
+
value
|
399
|
+
end
|
400
|
+
|
401
|
+
def newline_count
|
402
|
+
@text.count("\n")
|
403
|
+
end
|
404
|
+
|
405
|
+
def last_line_length
|
406
|
+
if @text.empty? || @text.end_with?("\n")
|
407
|
+
0
|
408
|
+
else
|
409
|
+
# Remove CSI sequences so they don't count against the length of the line because
|
410
|
+
# they are invisible in the terminal
|
411
|
+
@text.lines.last.gsub(/\e\[\d+;\d+;\d+m/, "").length
|
412
|
+
end
|
413
|
+
end
|
414
|
+
|
415
|
+
def to_s
|
416
|
+
@text
|
417
|
+
end
|
418
|
+
|
419
|
+
def inspect
|
420
|
+
%{#<#{self.class.name} newline_count=#{newline_count} last_line_length=#{last_line_length} text=#{@text.inspect}>}
|
421
|
+
end
|
422
|
+
end
|
423
|
+
|
424
|
+
class HostStatus
|
425
|
+
attr_accessor :row_number
|
426
|
+
|
427
|
+
def initialize(host, console)
|
428
|
+
@host = host
|
429
|
+
@console = console
|
430
|
+
|
431
|
+
@hostname = host.address
|
432
|
+
@state = Console::SGR("STARTING").with(text_color: :cyan)
|
433
|
+
@failure_message = ""
|
434
|
+
|
435
|
+
@console.puts self
|
436
|
+
end
|
437
|
+
|
438
|
+
def newline_count
|
439
|
+
1 + @failure_message.count("\n")
|
440
|
+
end
|
441
|
+
|
442
|
+
def last_line_length
|
443
|
+
0
|
444
|
+
end
|
445
|
+
|
446
|
+
def started!
|
447
|
+
@state = Console::SGR("RUNNING").with(text_color: :magenta)
|
448
|
+
|
449
|
+
@console.reprint_content(self)
|
450
|
+
end
|
451
|
+
|
452
|
+
def failed!(failure_message = "")
|
453
|
+
@state = Console::SGR("FAILED").with(text_color: :red, effect: :bold)
|
454
|
+
@failure_message = failure_message.to_s
|
455
|
+
|
456
|
+
@console.reprint_content(self)
|
457
|
+
end
|
458
|
+
|
459
|
+
def success!
|
460
|
+
@state = Console::SGR("DONE").with(text_color: :green)
|
461
|
+
|
462
|
+
@console.reprint_content(self)
|
463
|
+
end
|
464
|
+
|
465
|
+
def to_s
|
466
|
+
content = " %-#{Console.hostname_width}s\t[ %s ]\n" % [@hostname, @state]
|
467
|
+
|
468
|
+
unless @failure_message.empty?
|
469
|
+
indented_failure_message = @failure_message.each_line.
|
470
|
+
map { |line| line.prepend(" ") }.
|
471
|
+
join
|
472
|
+
|
473
|
+
content << Console::SGR(indented_failure_message).with(text_color: :yellow)
|
474
|
+
end
|
475
|
+
|
476
|
+
content
|
477
|
+
end
|
478
|
+
end
|
479
|
+
|
480
|
+
class SelectGraphicRendition
|
481
|
+
TEXT_COLORS = {
|
482
|
+
"red" => "31",
|
483
|
+
"green" => "32",
|
484
|
+
"yellow" => "33",
|
485
|
+
"blue" => "34",
|
486
|
+
"magenta" => "35",
|
487
|
+
"cyan" => "36",
|
488
|
+
"white" => "37",
|
489
|
+
}
|
490
|
+
|
491
|
+
BACKGROUND_COLORS = TEXT_COLORS.map { |name, value| [name, (value.to_i + 10).to_s] }.to_h
|
492
|
+
|
493
|
+
EFFECTS = {
|
494
|
+
"bold" => "1",
|
495
|
+
"faint" => "2",
|
496
|
+
"italic" => "3",
|
497
|
+
"underline" => "4",
|
498
|
+
"blink_slow" => "5",
|
499
|
+
"blink_fast" => "6",
|
500
|
+
"invert_colors" => "7",
|
501
|
+
"hide" => "8",
|
502
|
+
"strikethrough" => "9"
|
503
|
+
}
|
504
|
+
|
505
|
+
def initialize(text_color: "0", background_color: "0", effect: "0")
|
506
|
+
@text_color = TEXT_COLORS.fetch(text_color.to_s, text_color)
|
507
|
+
@background_color = BACKGROUND_COLORS.fetch(background_color.to_s, background_color)
|
508
|
+
@effect = EFFECTS.fetch(effect.to_s, effect)
|
509
|
+
end
|
510
|
+
|
511
|
+
def call(text)
|
512
|
+
"#{self}#{text}#{RESET}"
|
513
|
+
end
|
514
|
+
|
515
|
+
def wrap(text)
|
516
|
+
call(text)
|
517
|
+
end
|
518
|
+
|
519
|
+
def modify(**attrs)
|
520
|
+
combination = to_h.merge(attrs) do |key, old_value, new_value|
|
521
|
+
if old_value == "0"
|
522
|
+
new_value
|
523
|
+
elsif new_value == "0"
|
524
|
+
old_value
|
525
|
+
else
|
526
|
+
new_value
|
527
|
+
end
|
528
|
+
end
|
529
|
+
|
530
|
+
self.class.new(**combination)
|
531
|
+
end
|
532
|
+
|
533
|
+
def |(other)
|
534
|
+
modify(**other.to_h)
|
535
|
+
end
|
536
|
+
|
537
|
+
def to_str
|
538
|
+
"\e[#{@background_color};#{@effect};#{@text_color}m"
|
539
|
+
end
|
540
|
+
|
541
|
+
def to_s
|
542
|
+
to_str
|
543
|
+
end
|
544
|
+
|
545
|
+
def to_h
|
546
|
+
{
|
547
|
+
text_color: @text_color,
|
548
|
+
background_color: @background_color,
|
549
|
+
effect: @effect
|
550
|
+
}
|
551
|
+
end
|
552
|
+
|
553
|
+
RESET = new
|
554
|
+
|
555
|
+
class Renderer
|
556
|
+
def initialize(text)
|
557
|
+
@text = text
|
558
|
+
@select_graphic_rendition = SelectGraphicRendition.new
|
559
|
+
end
|
560
|
+
|
561
|
+
def with(**options)
|
562
|
+
@select_graphic_rendition = @select_graphic_rendition.modify(**options)
|
563
|
+
self
|
564
|
+
end
|
565
|
+
|
566
|
+
def to_s
|
567
|
+
@select_graphic_rendition.(@text)
|
568
|
+
end
|
569
|
+
|
570
|
+
def to_str
|
571
|
+
to_s
|
572
|
+
end
|
573
|
+
end
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
577
|
+
class Localhost
|
578
|
+
def address
|
579
|
+
"localhost"
|
580
|
+
end
|
581
|
+
|
582
|
+
def run
|
583
|
+
outcome = CommandOutcome.new
|
584
|
+
|
585
|
+
begin
|
586
|
+
outcome.command_started!
|
587
|
+
|
588
|
+
yield
|
589
|
+
rescue => error
|
590
|
+
outcome.error = error
|
591
|
+
end
|
592
|
+
|
593
|
+
outcome.command_finished!
|
594
|
+
outcome
|
595
|
+
end
|
596
|
+
|
597
|
+
class CommandOutcome
|
598
|
+
attr_accessor :error
|
599
|
+
|
600
|
+
def initialize
|
601
|
+
@console_status = Console::HostStatus.new(LOCALHOST, BoPeep.console)
|
602
|
+
@started_at = nil
|
603
|
+
@finished_at = nil
|
604
|
+
end
|
605
|
+
|
606
|
+
def host
|
607
|
+
LOCALHOST
|
608
|
+
end
|
609
|
+
|
610
|
+
def value
|
611
|
+
self
|
612
|
+
end
|
613
|
+
|
614
|
+
def command_started!
|
615
|
+
@started_at = Time.now
|
616
|
+
@started_at.freeze
|
617
|
+
|
618
|
+
@console_status.started!
|
619
|
+
end
|
620
|
+
|
621
|
+
def command_finished!
|
622
|
+
@finished_at = Time.now
|
623
|
+
@finished_at.freeze
|
624
|
+
|
625
|
+
if successful?
|
626
|
+
@console_status.success!
|
627
|
+
else
|
628
|
+
@console_status.failed!(failure_summary)
|
629
|
+
end
|
630
|
+
end
|
631
|
+
|
632
|
+
def successful?
|
633
|
+
error.nil?
|
634
|
+
end
|
635
|
+
|
636
|
+
def failed?
|
637
|
+
!successful?
|
638
|
+
end
|
639
|
+
|
640
|
+
def started?
|
641
|
+
not @started_at.nil?
|
642
|
+
end
|
643
|
+
|
644
|
+
def finished?
|
645
|
+
not @finished_at.nil?
|
646
|
+
end
|
647
|
+
|
648
|
+
def duration
|
649
|
+
if @started_at && @finished_at
|
650
|
+
@finished_at - @started_at
|
651
|
+
end
|
652
|
+
end
|
653
|
+
|
654
|
+
def failure_reason
|
655
|
+
error
|
656
|
+
end
|
657
|
+
|
658
|
+
def failure_summary
|
659
|
+
error && error.full_message
|
660
|
+
end
|
661
|
+
end
|
662
|
+
end
|
663
|
+
|
664
|
+
LOCALHOST = Localhost.new
|
665
|
+
|
666
|
+
class Host
|
667
|
+
module Parser
|
668
|
+
module_function
|
669
|
+
|
670
|
+
REGEXP = URI.regexp("ssh")
|
671
|
+
|
672
|
+
def call(host_string)
|
673
|
+
match_data = REGEXP.match("ssh://#{host_string}")
|
674
|
+
|
675
|
+
query_string = match_data[8] || ""
|
676
|
+
query_fragments = query_string.split("&")
|
677
|
+
|
678
|
+
properties = query_fragments.inject({}) do |all, fragment|
|
679
|
+
name, value = fragment.split("=", 2)
|
680
|
+
all.merge!(name.to_sym => value)
|
681
|
+
end
|
682
|
+
|
683
|
+
{
|
684
|
+
user: match_data[3],
|
685
|
+
address: match_data[4],
|
686
|
+
port: match_data[5],
|
687
|
+
properties: properties
|
688
|
+
}
|
689
|
+
end
|
690
|
+
end
|
691
|
+
|
692
|
+
def self.from(host_data)
|
693
|
+
case host_data
|
694
|
+
when Host
|
695
|
+
host_data
|
696
|
+
when Hash
|
697
|
+
attributes = host_data.transform_keys(&:to_sym)
|
698
|
+
|
699
|
+
new(**attributes)
|
700
|
+
when Array
|
701
|
+
if host_data.length == 1 && Hash === host_data[0]
|
702
|
+
from(host_data[0])
|
703
|
+
elsif String === host_data[0]
|
704
|
+
last_item_index = -1
|
705
|
+
attributes = Parser.(host_data[0])
|
706
|
+
|
707
|
+
if Hash === host_data[-1]
|
708
|
+
last_item_index = -2
|
709
|
+
|
710
|
+
more_properties = host_data[-1].transform_keys(&:to_sym)
|
711
|
+
attributes[:properties].merge!(more_properties)
|
712
|
+
end
|
713
|
+
|
714
|
+
host_data[1..last_item_index].each do |property|
|
715
|
+
name, value = property.to_s.split("=", 2)
|
716
|
+
attributes[:properties][name.to_sym] = value
|
717
|
+
end
|
718
|
+
|
719
|
+
new(**attributes)
|
720
|
+
else
|
721
|
+
raise InvalidHostDataError.new(host_data)
|
722
|
+
end
|
723
|
+
when String
|
724
|
+
new(**Parser.(host_data))
|
725
|
+
else
|
726
|
+
raise InvalidHostDataError.new(host_data)
|
727
|
+
end
|
728
|
+
end
|
729
|
+
|
730
|
+
attr_reader :address
|
731
|
+
attr_reader :id
|
732
|
+
attr_reader :port
|
733
|
+
attr_reader :uri
|
734
|
+
attr_reader :user
|
735
|
+
|
736
|
+
def initialize(user: nil, address:, port: nil, properties: {})
|
737
|
+
@user = user
|
738
|
+
@address = address
|
739
|
+
@port = port
|
740
|
+
@properties = properties.transform_keys(&:to_s)
|
741
|
+
|
742
|
+
build_uri!
|
743
|
+
end
|
744
|
+
|
745
|
+
def matches?(**filters)
|
746
|
+
filters.all? do |name, value|
|
747
|
+
if respond_to?(name)
|
748
|
+
send(name) == value
|
749
|
+
else
|
750
|
+
@properties[name.to_s] == value
|
751
|
+
end
|
752
|
+
end
|
753
|
+
end
|
754
|
+
|
755
|
+
def [](name)
|
756
|
+
@properties[name.to_s]
|
757
|
+
end
|
758
|
+
|
759
|
+
def []=(name, value)
|
760
|
+
@properties[name.to_s] = value
|
761
|
+
|
762
|
+
build_uri!
|
763
|
+
|
764
|
+
value
|
765
|
+
end
|
766
|
+
|
767
|
+
def ==(other)
|
768
|
+
self.class == other.class && uri == other.uri
|
769
|
+
end
|
770
|
+
|
771
|
+
alias eql? ==
|
772
|
+
|
773
|
+
def hash
|
774
|
+
inspect.hash
|
775
|
+
end
|
776
|
+
|
777
|
+
def run_command(command, &setup)
|
778
|
+
outcome = CommandOutcome.new(self, command, &setup)
|
779
|
+
|
780
|
+
connection.open_channel do |channel|
|
781
|
+
channel.exec(command) do |_, success|
|
782
|
+
outcome.command_started!
|
783
|
+
|
784
|
+
channel.on_data do |_, data|
|
785
|
+
outcome.collect_stdout(data)
|
786
|
+
end
|
787
|
+
|
788
|
+
channel.on_extended_data do |_, __, data|
|
789
|
+
outcome.collect_stderr(data)
|
790
|
+
end
|
791
|
+
|
792
|
+
channel.on_request("exit-status") do |_, data|
|
793
|
+
outcome.exit_status = data.read_long
|
794
|
+
end
|
795
|
+
|
796
|
+
channel.on_request("exit-signal") do |_, data|
|
797
|
+
outcome.exit_signal = data.read_string
|
798
|
+
end
|
799
|
+
|
800
|
+
channel.on_open_failed do |_, code, reason|
|
801
|
+
outcome.connection_failed(code, reason)
|
802
|
+
end
|
803
|
+
|
804
|
+
channel.on_close do |_|
|
805
|
+
outcome.command_finished!
|
806
|
+
end
|
807
|
+
end
|
808
|
+
|
809
|
+
channel.wait
|
810
|
+
end
|
811
|
+
|
812
|
+
connection.loop
|
813
|
+
|
814
|
+
outcome
|
815
|
+
rescue SocketError, Errno::ECONNREFUSED, Net::SSH::AuthenticationFailed => error
|
816
|
+
outcome.connection_failed(-1, "#{error.class}: #{error.message}")
|
817
|
+
outcome
|
818
|
+
end
|
819
|
+
|
820
|
+
def run_script(script, &setup)
|
821
|
+
create_directory script.install_directory
|
822
|
+
|
823
|
+
create_file_from script.content, path: script.install_path, mode: 0755
|
824
|
+
|
825
|
+
run_command(script.remote_path, &setup)
|
826
|
+
end
|
827
|
+
|
828
|
+
def create_directory(directory)
|
829
|
+
command = "mkdir -p #{directory}"
|
830
|
+
|
831
|
+
connection.open_channel do |channel|
|
832
|
+
channel.exec(command)
|
833
|
+
channel.wait
|
834
|
+
end
|
835
|
+
|
836
|
+
connection.loop
|
837
|
+
end
|
838
|
+
|
839
|
+
def create_file_from(content, path:, mode: 0644)
|
840
|
+
filename = "#{id}-#{File.basename(path)}"
|
841
|
+
|
842
|
+
tempfile = Tempfile.new(filename)
|
843
|
+
tempfile.chmod(mode)
|
844
|
+
tempfile.write(content)
|
845
|
+
tempfile.rewind
|
846
|
+
|
847
|
+
upload tempfile, to: path
|
848
|
+
|
849
|
+
tempfile.unlink
|
850
|
+
end
|
851
|
+
|
852
|
+
def upload(file, to:)
|
853
|
+
connection.scp.upload!(file, to)
|
854
|
+
end
|
855
|
+
|
856
|
+
def download(path, to: nil)
|
857
|
+
content = connection.scp.download!(path)
|
858
|
+
|
859
|
+
if to
|
860
|
+
file = File.new(to, "w+b")
|
861
|
+
else
|
862
|
+
file = Tempfile.new(path)
|
863
|
+
end
|
864
|
+
|
865
|
+
file.write(content)
|
866
|
+
file.rewind
|
867
|
+
|
868
|
+
file
|
869
|
+
end
|
870
|
+
|
871
|
+
def inspect
|
872
|
+
%{#<#{self.class.name} uri=#{@uri.to_s}>}
|
873
|
+
end
|
874
|
+
|
875
|
+
def to_s
|
876
|
+
@uri.to_s
|
877
|
+
end
|
878
|
+
|
879
|
+
private
|
880
|
+
|
881
|
+
def connection
|
882
|
+
if defined?(@connection)
|
883
|
+
@connection
|
884
|
+
else
|
885
|
+
options = { non_interactive: true }
|
886
|
+
|
887
|
+
if port
|
888
|
+
options[:port] = port
|
889
|
+
end
|
890
|
+
|
891
|
+
@connection = Net::SSH.start(address, user || BoPeep.default_user, options)
|
892
|
+
end
|
893
|
+
end
|
894
|
+
|
895
|
+
def build_uri!
|
896
|
+
@uri = URI.parse("ssh://")
|
897
|
+
|
898
|
+
@uri.user = @user
|
899
|
+
@uri.host = @address
|
900
|
+
@uri.port = @port
|
901
|
+
|
902
|
+
unless @properties.empty?
|
903
|
+
@uri.query = @properties.map { |name, value| "#{name}=#{value}" }.join("&")
|
904
|
+
end
|
905
|
+
|
906
|
+
@id = Digest::SHA1.hexdigest(@uri.to_s)
|
907
|
+
end
|
908
|
+
|
909
|
+
class CommandOutcome
|
910
|
+
attr_reader :command
|
911
|
+
attr_reader :connection_error_code
|
912
|
+
attr_reader :connection_error_reason
|
913
|
+
attr_reader :console_state
|
914
|
+
attr_reader :exit_signal
|
915
|
+
attr_reader :exit_status
|
916
|
+
attr_reader :failure_reason
|
917
|
+
attr_reader :finished_at
|
918
|
+
attr_reader :host
|
919
|
+
attr_reader :started_at
|
920
|
+
attr_reader :stderr
|
921
|
+
attr_reader :stdout
|
922
|
+
|
923
|
+
def initialize(host, command, &setup)
|
924
|
+
@host = host
|
925
|
+
@command = command
|
926
|
+
|
927
|
+
@console_status = Console::HostStatus.new(host, BoPeep.console)
|
928
|
+
|
929
|
+
@stdout = ""
|
930
|
+
@stdout_callback = proc {}
|
931
|
+
|
932
|
+
@stderr = ""
|
933
|
+
@stderr_callback = proc {}
|
934
|
+
|
935
|
+
@started_at = nil
|
936
|
+
@finished_at = nil
|
937
|
+
|
938
|
+
if setup
|
939
|
+
instance_eval(&setup)
|
940
|
+
end
|
941
|
+
end
|
942
|
+
|
943
|
+
def value
|
944
|
+
self
|
945
|
+
end
|
946
|
+
|
947
|
+
def on_stdout(&block)
|
948
|
+
@stdout_callback = block
|
949
|
+
end
|
950
|
+
|
951
|
+
def collect_stdout(data)
|
952
|
+
@stdout << data
|
953
|
+
@stdout_callback.call(data)
|
954
|
+
end
|
955
|
+
|
956
|
+
def on_stderr(&block)
|
957
|
+
@stderr_callback = block
|
958
|
+
end
|
959
|
+
|
960
|
+
def collect_stderr(data)
|
961
|
+
@stderr << data
|
962
|
+
@stderr_callback.call(data)
|
963
|
+
end
|
964
|
+
|
965
|
+
def successful?
|
966
|
+
exit_status && exit_status.zero?
|
967
|
+
end
|
968
|
+
|
969
|
+
def failed?
|
970
|
+
!successful?
|
971
|
+
end
|
972
|
+
|
973
|
+
def started?
|
974
|
+
not @started_at.nil?
|
975
|
+
end
|
976
|
+
|
977
|
+
def finished?
|
978
|
+
not @finished_at.nil?
|
979
|
+
end
|
980
|
+
|
981
|
+
def exit_status=(value)
|
982
|
+
if finished?
|
983
|
+
$stderr.puts "[WARN] cannot change `#exit_status` after command has finished"
|
984
|
+
else
|
985
|
+
@exit_status = value
|
986
|
+
|
987
|
+
if failed?
|
988
|
+
@failure_reason = :nonzero_exit_status
|
989
|
+
end
|
990
|
+
end
|
991
|
+
|
992
|
+
value
|
993
|
+
end
|
994
|
+
|
995
|
+
def exit_signal=(value)
|
996
|
+
if finished?
|
997
|
+
$stderr.puts "[WARN] cannot change `#exit_signal` after command has finished"
|
998
|
+
else
|
999
|
+
@exit_signal = value
|
1000
|
+
@exit_signal.freeze
|
1001
|
+
|
1002
|
+
@failure_reason = :exit_signal
|
1003
|
+
end
|
1004
|
+
|
1005
|
+
value
|
1006
|
+
end
|
1007
|
+
|
1008
|
+
def duration
|
1009
|
+
if @finished_at && @started_at
|
1010
|
+
@finished_at - @started_at
|
1011
|
+
end
|
1012
|
+
end
|
1013
|
+
|
1014
|
+
def command_started!
|
1015
|
+
if @started_at
|
1016
|
+
$stderr.puts "[WARN] command already started"
|
1017
|
+
else
|
1018
|
+
@started_at = Time.now
|
1019
|
+
@started_at.freeze
|
1020
|
+
|
1021
|
+
@console_status.started!
|
1022
|
+
end
|
1023
|
+
end
|
1024
|
+
|
1025
|
+
def command_finished!
|
1026
|
+
if finished?
|
1027
|
+
$stderr.puts "[WARN] command already finished"
|
1028
|
+
else
|
1029
|
+
@stdout.freeze
|
1030
|
+
@stderr.freeze
|
1031
|
+
|
1032
|
+
@finished_at = Time.now
|
1033
|
+
@finished_at.freeze
|
1034
|
+
|
1035
|
+
if successful?
|
1036
|
+
@console_status.success!
|
1037
|
+
else
|
1038
|
+
@console_status.failed!(failure_summary)
|
1039
|
+
end
|
1040
|
+
end
|
1041
|
+
end
|
1042
|
+
|
1043
|
+
def connection_failed(code, reason)
|
1044
|
+
@connection_error_code = code.freeze
|
1045
|
+
@connection_error_reason = reason.freeze
|
1046
|
+
|
1047
|
+
@failure_reason = :connection_error
|
1048
|
+
|
1049
|
+
unless started?
|
1050
|
+
@console_status.failed!(failure_summary)
|
1051
|
+
end
|
1052
|
+
end
|
1053
|
+
|
1054
|
+
def failure_summary
|
1055
|
+
case failure_reason
|
1056
|
+
when :exit_signal, :nonzero_exit_status
|
1057
|
+
adjusted_stdout, adjusted_stderr = [stdout, stderr].map do |output|
|
1058
|
+
adjusted = output.each_line.map { |line| line.prepend(" ") }.join.chomp
|
1059
|
+
|
1060
|
+
if adjusted.empty?
|
1061
|
+
adjusted = "(empty)"
|
1062
|
+
end
|
1063
|
+
|
1064
|
+
adjusted
|
1065
|
+
end
|
1066
|
+
|
1067
|
+
<<~OUTPUT
|
1068
|
+
STDOUT: #{adjusted_stdout}
|
1069
|
+
STDERR: #{adjusted_stderr}
|
1070
|
+
OUTPUT
|
1071
|
+
when :connection_error
|
1072
|
+
<<~OUTPUT
|
1073
|
+
Connection failed:
|
1074
|
+
Code: #{connection_error_code}
|
1075
|
+
Reason: #{connection_error_reason}
|
1076
|
+
OUTPUT
|
1077
|
+
end
|
1078
|
+
end
|
1079
|
+
end
|
1080
|
+
end
|
1081
|
+
|
1082
|
+
class HostCollection
|
1083
|
+
include Enumerable
|
1084
|
+
|
1085
|
+
def self.from(value)
|
1086
|
+
case value
|
1087
|
+
when String, Host
|
1088
|
+
new.add(value)
|
1089
|
+
when HostCollection
|
1090
|
+
value
|
1091
|
+
when Array
|
1092
|
+
is_array_host_specification = value.any? do |item|
|
1093
|
+
# Check key=value items by looking for `=` missing `?`. If it has `?`, then we
|
1094
|
+
# assume it is in the form `host?key=value`
|
1095
|
+
String === item and item.index("=") and not item.index("?")
|
1096
|
+
end
|
1097
|
+
|
1098
|
+
if is_array_host_specification
|
1099
|
+
new.add(value)
|
1100
|
+
else
|
1101
|
+
value.inject(new) { |collection, host_data| collection.add(host_data) }
|
1102
|
+
end
|
1103
|
+
when Hash
|
1104
|
+
value.inject(new) do |collection, (property, hosts_data)|
|
1105
|
+
name, value = property.to_s.split(":", 2)
|
1106
|
+
|
1107
|
+
if value.nil?
|
1108
|
+
value = name
|
1109
|
+
name = "stage"
|
1110
|
+
end
|
1111
|
+
|
1112
|
+
from(hosts_data).each do |host|
|
1113
|
+
host[name] = value
|
1114
|
+
collection.add(host)
|
1115
|
+
end
|
1116
|
+
|
1117
|
+
collection
|
1118
|
+
end
|
1119
|
+
when nil
|
1120
|
+
new
|
1121
|
+
else
|
1122
|
+
raise InvalidHostDataError.new(value)
|
1123
|
+
end
|
1124
|
+
end
|
1125
|
+
|
1126
|
+
def initialize
|
1127
|
+
@hosts = []
|
1128
|
+
end
|
1129
|
+
|
1130
|
+
def one
|
1131
|
+
if @hosts.empty?
|
1132
|
+
raise "cannot pick a host from `#{inspect}'; collection is empty"
|
1133
|
+
end
|
1134
|
+
|
1135
|
+
HostCollection.from @hosts.sample
|
1136
|
+
end
|
1137
|
+
|
1138
|
+
def add(host_data)
|
1139
|
+
@hosts << Host.from(host_data)
|
1140
|
+
|
1141
|
+
self
|
1142
|
+
end
|
1143
|
+
|
1144
|
+
def with(**filters)
|
1145
|
+
HostCollection.from(select { |host| host.matches?(**filters) })
|
1146
|
+
end
|
1147
|
+
|
1148
|
+
def ==(other)
|
1149
|
+
self.class == other.class &&
|
1150
|
+
length == other.length &&
|
1151
|
+
# both are the same length and their intersection is the same length (all the same
|
1152
|
+
# elements in common)
|
1153
|
+
length == @hosts.&(other.hosts).length
|
1154
|
+
end
|
1155
|
+
|
1156
|
+
alias eql? ==
|
1157
|
+
|
1158
|
+
def length
|
1159
|
+
@hosts.length
|
1160
|
+
end
|
1161
|
+
|
1162
|
+
def lazy
|
1163
|
+
LazilyFilteredHostCollection.new(self)
|
1164
|
+
end
|
1165
|
+
|
1166
|
+
def each
|
1167
|
+
if block_given?
|
1168
|
+
@hosts.each { |host| yield host }
|
1169
|
+
else
|
1170
|
+
enum_for(:each)
|
1171
|
+
end
|
1172
|
+
|
1173
|
+
self
|
1174
|
+
end
|
1175
|
+
|
1176
|
+
module Interaction
|
1177
|
+
def create_file_from(content, path:, mode: 0644)
|
1178
|
+
each do |host|
|
1179
|
+
host.create_file_from(content, path: path, mode: mode)
|
1180
|
+
end
|
1181
|
+
end
|
1182
|
+
|
1183
|
+
def upload(file, to:)
|
1184
|
+
each do |host|
|
1185
|
+
host.upload(file, to: to)
|
1186
|
+
end
|
1187
|
+
end
|
1188
|
+
|
1189
|
+
def download(path)
|
1190
|
+
map do |host|
|
1191
|
+
host.download(path)
|
1192
|
+
end
|
1193
|
+
end
|
1194
|
+
|
1195
|
+
def run_script(script, order: :parallel, &setup)
|
1196
|
+
run(order: order) do |host, result|
|
1197
|
+
result.update host.run_script(script, &setup)
|
1198
|
+
end
|
1199
|
+
end
|
1200
|
+
|
1201
|
+
def run_command(command, order: :parallel, &setup)
|
1202
|
+
run(order: order) do |host, result|
|
1203
|
+
result.update host.run_command(command, &setup)
|
1204
|
+
end
|
1205
|
+
end
|
1206
|
+
|
1207
|
+
def run(order:, &block)
|
1208
|
+
strategy = Executor.for(order)
|
1209
|
+
executor = strategy.new(self)
|
1210
|
+
executor.run(&block)
|
1211
|
+
end
|
1212
|
+
end
|
1213
|
+
|
1214
|
+
include Interaction
|
1215
|
+
|
1216
|
+
def to_a
|
1217
|
+
@hosts.dup
|
1218
|
+
end
|
1219
|
+
|
1220
|
+
protected
|
1221
|
+
|
1222
|
+
attr_reader :hosts
|
1223
|
+
end
|
1224
|
+
|
1225
|
+
class LazilyFilteredHostCollection
|
1226
|
+
include Enumerable
|
1227
|
+
include HostCollection::Interaction
|
1228
|
+
|
1229
|
+
attr_reader :filters
|
1230
|
+
|
1231
|
+
def self.from(value)
|
1232
|
+
new HostCollection.from(value)
|
1233
|
+
end
|
1234
|
+
|
1235
|
+
def initialize(hosts)
|
1236
|
+
@hosts = hosts
|
1237
|
+
@filters = {}
|
1238
|
+
end
|
1239
|
+
|
1240
|
+
def one
|
1241
|
+
host = to_a.sample
|
1242
|
+
|
1243
|
+
if host.nil?
|
1244
|
+
raise "no hosts matched `#{inspect}'"
|
1245
|
+
else
|
1246
|
+
HostCollection.from(host)
|
1247
|
+
end
|
1248
|
+
end
|
1249
|
+
|
1250
|
+
def add(host_data)
|
1251
|
+
host = Host.from(host_data)
|
1252
|
+
|
1253
|
+
if host.matches?(**@filters)
|
1254
|
+
@hosts.add(host)
|
1255
|
+
self
|
1256
|
+
else
|
1257
|
+
false
|
1258
|
+
end
|
1259
|
+
end
|
1260
|
+
|
1261
|
+
def with(**filters)
|
1262
|
+
combined = @filters.merge(filters)
|
1263
|
+
|
1264
|
+
HostCollection.new(@hosts.select { |host| host.matches?(combined) })
|
1265
|
+
end
|
1266
|
+
|
1267
|
+
def ==(other)
|
1268
|
+
case other
|
1269
|
+
when HostCollection, LazilyFilteredHostCollection
|
1270
|
+
to_a == other.to_a
|
1271
|
+
when Enumerable
|
1272
|
+
to_a == other
|
1273
|
+
else
|
1274
|
+
super
|
1275
|
+
end
|
1276
|
+
end
|
1277
|
+
|
1278
|
+
alias eql? ==
|
1279
|
+
|
1280
|
+
def length
|
1281
|
+
count
|
1282
|
+
end
|
1283
|
+
|
1284
|
+
def each
|
1285
|
+
if block_given?
|
1286
|
+
@hosts.each do |host|
|
1287
|
+
if host.matches?(**@filters)
|
1288
|
+
yield host
|
1289
|
+
end
|
1290
|
+
end
|
1291
|
+
else
|
1292
|
+
enum_for(:each)
|
1293
|
+
end
|
1294
|
+
end
|
1295
|
+
end
|
1296
|
+
|
1297
|
+
class Config
|
1298
|
+
attr_accessor :default_user
|
1299
|
+
attr_reader :hosts
|
1300
|
+
attr_reader :variables_sets
|
1301
|
+
|
1302
|
+
def initialize
|
1303
|
+
@hosts = HostCollection.new
|
1304
|
+
@variables_sets = Set.new
|
1305
|
+
end
|
1306
|
+
|
1307
|
+
def hosts=(value)
|
1308
|
+
@hosts = HostCollection.from(value)
|
1309
|
+
end
|
1310
|
+
|
1311
|
+
def variables_set_defined?(name)
|
1312
|
+
@variables_sets.include?(name.to_s)
|
1313
|
+
end
|
1314
|
+
|
1315
|
+
def [](name)
|
1316
|
+
if variables_set_defined?(name.to_s)
|
1317
|
+
instance_variable_get("@#{name}")
|
1318
|
+
end
|
1319
|
+
end
|
1320
|
+
|
1321
|
+
def variables(name, &block)
|
1322
|
+
variables_name = name.to_s
|
1323
|
+
ivar_name = "@#{variables_name}"
|
1324
|
+
|
1325
|
+
if @variables_sets.include?(variables_name)
|
1326
|
+
variables_object = instance_variable_get(ivar_name)
|
1327
|
+
else
|
1328
|
+
@variables_sets << variables_name
|
1329
|
+
|
1330
|
+
singleton_class.send(:attr_reader, variables_name)
|
1331
|
+
variables_object = instance_variable_set(ivar_name, VariableSet.new(variables_name, self))
|
1332
|
+
end
|
1333
|
+
|
1334
|
+
block.call(variables_object)
|
1335
|
+
end
|
1336
|
+
|
1337
|
+
class VariableSet
|
1338
|
+
attr_reader :context
|
1339
|
+
|
1340
|
+
def initialize(name, context)
|
1341
|
+
@_name = name
|
1342
|
+
@context = context
|
1343
|
+
# FIXME: make this "private" by adding an underscore
|
1344
|
+
@properties = Set.new
|
1345
|
+
end
|
1346
|
+
|
1347
|
+
def context=(*)
|
1348
|
+
raise NotImplementedError
|
1349
|
+
end
|
1350
|
+
|
1351
|
+
def defined?(property_name)
|
1352
|
+
@properties.include?(property_name.to_s)
|
1353
|
+
end
|
1354
|
+
|
1355
|
+
def define!(property_name)
|
1356
|
+
@properties << property_name.to_s
|
1357
|
+
end
|
1358
|
+
|
1359
|
+
def copy(other)
|
1360
|
+
other.properties.each do |property_name|
|
1361
|
+
value = other.instance_variable_get("@#{property_name}")
|
1362
|
+
|
1363
|
+
extend Property.new(property_name, value)
|
1364
|
+
end
|
1365
|
+
end
|
1366
|
+
|
1367
|
+
def to_h
|
1368
|
+
@properties.each_with_object({}) do |property_name, variables|
|
1369
|
+
variables["#{@_name}_#{property_name}"] = send(property_name)
|
1370
|
+
end
|
1371
|
+
end
|
1372
|
+
|
1373
|
+
protected
|
1374
|
+
|
1375
|
+
attr_reader :properties
|
1376
|
+
|
1377
|
+
def method_missing(name, *args, &block)
|
1378
|
+
writer_name = name.to_s
|
1379
|
+
reader_name = writer_name.chomp("=")
|
1380
|
+
|
1381
|
+
if reader_name !~ Property::REGEXP
|
1382
|
+
super
|
1383
|
+
elsif reader_name == writer_name && block.nil?
|
1384
|
+
$stderr.puts "`#{@_name}.#{reader_name}' was accessed before it was defined"
|
1385
|
+
nil
|
1386
|
+
elsif writer_name.end_with?("=") or not block.nil?
|
1387
|
+
value = block || args
|
1388
|
+
|
1389
|
+
extend Property.new(reader_name, *value)
|
1390
|
+
end
|
1391
|
+
end
|
1392
|
+
|
1393
|
+
private
|
1394
|
+
|
1395
|
+
class Property < Module
|
1396
|
+
REGEXP = /^[A-Za-z_]+$/
|
1397
|
+
|
1398
|
+
def initialize(name, initial_value = nil)
|
1399
|
+
@name = name
|
1400
|
+
@initial_value = initial_value
|
1401
|
+
end
|
1402
|
+
|
1403
|
+
def extended(variables_set)
|
1404
|
+
variables_set.define! @name
|
1405
|
+
|
1406
|
+
variables_set.singleton_class.class_eval <<-PROPERTY_METHODS
|
1407
|
+
attr_writer :#{@name}
|
1408
|
+
|
1409
|
+
def #{@name}(&block)
|
1410
|
+
if block.nil?
|
1411
|
+
value = instance_variable_get(:@#{@name})
|
1412
|
+
|
1413
|
+
if value.respond_to?(:to_proc)
|
1414
|
+
instance_eval(&value)
|
1415
|
+
else
|
1416
|
+
value
|
1417
|
+
end
|
1418
|
+
else
|
1419
|
+
instance_variable_set(:@#{@name}, block)
|
1420
|
+
end
|
1421
|
+
end
|
1422
|
+
PROPERTY_METHODS
|
1423
|
+
|
1424
|
+
variables_set.instance_variable_set("@#{@name}", @initial_value)
|
1425
|
+
end
|
1426
|
+
end
|
1427
|
+
end
|
1428
|
+
end
|
1429
|
+
|
1430
|
+
class Context < Config
|
1431
|
+
attr_reader :argv
|
1432
|
+
attr_reader :filters
|
1433
|
+
attr_reader :parser
|
1434
|
+
attr_reader :playlist
|
1435
|
+
|
1436
|
+
def initialize(argv, playlist)
|
1437
|
+
@argv = argv
|
1438
|
+
@playlist = playlist
|
1439
|
+
@parser = OptionParser.new
|
1440
|
+
|
1441
|
+
@parser.on("-t", "--target HOSTS", Array, "hosts to use") do |hosts_data|
|
1442
|
+
hosts_data.each { |host_data| hosts.add(host_data) }
|
1443
|
+
end
|
1444
|
+
|
1445
|
+
@parser.on("-f", "--filter key=value", String, "filters to apply to hosts") do |filter|
|
1446
|
+
key, value = filter.split("=", 2)
|
1447
|
+
|
1448
|
+
@hosts.filters[key] = value
|
1449
|
+
end
|
1450
|
+
|
1451
|
+
super()
|
1452
|
+
|
1453
|
+
@hosts = @hosts.lazy
|
1454
|
+
end
|
1455
|
+
|
1456
|
+
def parse_options!
|
1457
|
+
@parser.parse(@argv)
|
1458
|
+
end
|
1459
|
+
|
1460
|
+
def copy(config)
|
1461
|
+
config.hosts.each do |host|
|
1462
|
+
hosts.add(host)
|
1463
|
+
end
|
1464
|
+
|
1465
|
+
config.variables_sets.each do |variables_name|
|
1466
|
+
variables(variables_name) do |variables_object|
|
1467
|
+
variables_object.copy config.instance_variable_get("@#{variables_name}")
|
1468
|
+
end
|
1469
|
+
end
|
1470
|
+
end
|
1471
|
+
end
|
1472
|
+
|
1473
|
+
class Playlist
|
1474
|
+
attr_reader :insert_at
|
1475
|
+
|
1476
|
+
def initialize
|
1477
|
+
@start = @insert_at = Start.new
|
1478
|
+
end
|
1479
|
+
|
1480
|
+
def play
|
1481
|
+
playing = @insert_at = @start
|
1482
|
+
command = nil
|
1483
|
+
|
1484
|
+
while playing.next
|
1485
|
+
playing = @insert_at = playing.next
|
1486
|
+
|
1487
|
+
if command != playing.command
|
1488
|
+
command = playing.command
|
1489
|
+
|
1490
|
+
BoPeep.console.puts Console::SGR(command.command_name.console).with(text_color: :blue, effect: :bold)
|
1491
|
+
end
|
1492
|
+
|
1493
|
+
BoPeep.console.puts Console::SGR(" -> #{playing.banner}").with(text_color: :magenta)
|
1494
|
+
|
1495
|
+
result = playing.run
|
1496
|
+
|
1497
|
+
if result.failed?
|
1498
|
+
command.on_failure(result)
|
1499
|
+
end
|
1500
|
+
end
|
1501
|
+
end
|
1502
|
+
|
1503
|
+
def queue(command_step)
|
1504
|
+
if @insert_at.next
|
1505
|
+
command_step.next = @insert_at.next
|
1506
|
+
end
|
1507
|
+
|
1508
|
+
command_step.previous = @insert_at
|
1509
|
+
@insert_at.next = command_step
|
1510
|
+
|
1511
|
+
@insert_at = command_step
|
1512
|
+
end
|
1513
|
+
|
1514
|
+
class Start
|
1515
|
+
attr_accessor :next
|
1516
|
+
|
1517
|
+
def previous
|
1518
|
+
end
|
1519
|
+
end
|
1520
|
+
end
|
1521
|
+
|
1522
|
+
class Runner
|
1523
|
+
def initialize(argv)
|
1524
|
+
@argv = argv.dup
|
1525
|
+
@path = nil
|
1526
|
+
@playlist = Playlist.new
|
1527
|
+
|
1528
|
+
find_bopeep_directory!
|
1529
|
+
load_config!
|
1530
|
+
|
1531
|
+
@context = Context.new(@argv, @playlist)
|
1532
|
+
@context.copy(BoPeep.config)
|
1533
|
+
|
1534
|
+
load_scripts!
|
1535
|
+
load_commands!
|
1536
|
+
|
1537
|
+
@command = Command.find(@argv)
|
1538
|
+
end
|
1539
|
+
|
1540
|
+
def run
|
1541
|
+
if @command.nil?
|
1542
|
+
@context.parse_options!
|
1543
|
+
$stderr.puts "Could not find command from #{@argv.inspect}"
|
1544
|
+
exit 1
|
1545
|
+
end
|
1546
|
+
|
1547
|
+
@command.(@context)
|
1548
|
+
@context.parse_options!
|
1549
|
+
|
1550
|
+
Console.hostname_width = @context.hosts.map { |host| host.address.length }.max
|
1551
|
+
|
1552
|
+
BoPeep.console.redirecting_stdout_and_stderr do
|
1553
|
+
@playlist.play
|
1554
|
+
end
|
1555
|
+
end
|
1556
|
+
|
1557
|
+
private
|
1558
|
+
|
1559
|
+
def find_bopeep_directory!
|
1560
|
+
previous_directory = nil
|
1561
|
+
current_directory = Pathname.new(Dir.pwd)
|
1562
|
+
|
1563
|
+
while @path.nil? && current_directory.directory? && current_directory != previous_directory
|
1564
|
+
target = current_directory.join("bopeep")
|
1565
|
+
|
1566
|
+
if target.directory?
|
1567
|
+
@path = target
|
1568
|
+
|
1569
|
+
BoPeep.search_paths.unshift(@path)
|
1570
|
+
BoPeep.search_paths.uniq!
|
1571
|
+
else
|
1572
|
+
previous_directory = current_directory
|
1573
|
+
current_directory = current_directory.parent
|
1574
|
+
end
|
1575
|
+
end
|
1576
|
+
|
1577
|
+
if @path.nil?
|
1578
|
+
$stderr.puts "`bopeep' directory not found in current directory or ancestors"
|
1579
|
+
exit 1
|
1580
|
+
end
|
1581
|
+
end
|
1582
|
+
|
1583
|
+
def load_config!
|
1584
|
+
config_path = @path.join("config.rb")
|
1585
|
+
|
1586
|
+
if config_path.exist?
|
1587
|
+
load config_path
|
1588
|
+
end
|
1589
|
+
|
1590
|
+
Dir.glob(@path.join("config", "**", "*.rb")).each do |config_file|
|
1591
|
+
load config_file
|
1592
|
+
end
|
1593
|
+
end
|
1594
|
+
|
1595
|
+
def load_commands!
|
1596
|
+
BoPeep.search_paths.each do |bopeep_path|
|
1597
|
+
Dir.glob(bopeep_path.join("commands", "**", "*.rb")).each do |command_path|
|
1598
|
+
require command_path
|
1599
|
+
end
|
1600
|
+
end
|
1601
|
+
end
|
1602
|
+
|
1603
|
+
def load_scripts!
|
1604
|
+
BoPeep.search_paths.each do |bopeep_path|
|
1605
|
+
bopeep_scripts_path = bopeep_path.join("scripts")
|
1606
|
+
|
1607
|
+
Dir.glob(bopeep_scripts_path.join("**", "*")).each do |path|
|
1608
|
+
script_path = Pathname.new(path)
|
1609
|
+
|
1610
|
+
if script_path.directory? || script_path.basename.to_s.index("_defaults") == 0
|
1611
|
+
next
|
1612
|
+
end
|
1613
|
+
|
1614
|
+
relative_script_path = script_path.relative_path_from(bopeep_scripts_path)
|
1615
|
+
|
1616
|
+
command_name = Command::Name.from_relative_script_path(relative_script_path)
|
1617
|
+
|
1618
|
+
object_names = command_name.object.split("::")
|
1619
|
+
object_names.inject(Object) do |namespace, object_name|
|
1620
|
+
if namespace.const_defined?(object_name)
|
1621
|
+
object = namespace.const_get(object_name)
|
1622
|
+
else
|
1623
|
+
if object_name == object_names[-1]
|
1624
|
+
object = Class.new do
|
1625
|
+
include Command::BehaviorWithoutRegistration
|
1626
|
+
|
1627
|
+
def run
|
1628
|
+
run_script
|
1629
|
+
end
|
1630
|
+
end
|
1631
|
+
else
|
1632
|
+
object = Module.new
|
1633
|
+
end
|
1634
|
+
|
1635
|
+
namespace.const_set(object_name, object)
|
1636
|
+
|
1637
|
+
if object < Command::BehaviorWithoutRegistration
|
1638
|
+
BoPeep::Command.register(object)
|
1639
|
+
end
|
1640
|
+
end
|
1641
|
+
|
1642
|
+
object
|
1643
|
+
end
|
1644
|
+
end
|
1645
|
+
end
|
1646
|
+
end
|
1647
|
+
end
|
1648
|
+
|
1649
|
+
class Executor
|
1650
|
+
class << self
|
1651
|
+
attr_reader :strategies
|
1652
|
+
end
|
1653
|
+
|
1654
|
+
@strategies = {}
|
1655
|
+
|
1656
|
+
def self.for(name)
|
1657
|
+
@strategies.fetch(name)
|
1658
|
+
end
|
1659
|
+
|
1660
|
+
def self.register(name, &block)
|
1661
|
+
strategy = Class.new(self)
|
1662
|
+
strategy.send(:define_method, :in_order, &block)
|
1663
|
+
|
1664
|
+
@strategies[name] = strategy
|
1665
|
+
end
|
1666
|
+
|
1667
|
+
attr_reader :collection
|
1668
|
+
attr_reader :result
|
1669
|
+
|
1670
|
+
def initialize(collection)
|
1671
|
+
@collection = collection
|
1672
|
+
@result = Result.new
|
1673
|
+
end
|
1674
|
+
|
1675
|
+
# FIXME: error handling?
|
1676
|
+
def run(&block)
|
1677
|
+
in_order(&block)
|
1678
|
+
result
|
1679
|
+
end
|
1680
|
+
|
1681
|
+
def in_order(&block)
|
1682
|
+
raise NotImplementedError
|
1683
|
+
end
|
1684
|
+
|
1685
|
+
register :parallel do |&procedure|
|
1686
|
+
threads = collection.map do |object|
|
1687
|
+
Thread.new do
|
1688
|
+
procedure.call(object, result)
|
1689
|
+
end
|
1690
|
+
end
|
1691
|
+
|
1692
|
+
threads.each(&:join)
|
1693
|
+
end
|
1694
|
+
|
1695
|
+
register :serial do |&procedure|
|
1696
|
+
collection.each do |object|
|
1697
|
+
procedure.call(object, result)
|
1698
|
+
end
|
1699
|
+
end
|
1700
|
+
|
1701
|
+
class Result
|
1702
|
+
include Enumerable
|
1703
|
+
extend Forwardable
|
1704
|
+
|
1705
|
+
def_delegator :value, :each
|
1706
|
+
|
1707
|
+
attr_reader :value
|
1708
|
+
|
1709
|
+
def initialize
|
1710
|
+
@mutex = Mutex.new
|
1711
|
+
@successful = true
|
1712
|
+
@value = []
|
1713
|
+
end
|
1714
|
+
|
1715
|
+
def update(outcome)
|
1716
|
+
@mutex.synchronize do
|
1717
|
+
@value << outcome
|
1718
|
+
@successful &&= outcome.successful?
|
1719
|
+
end
|
1720
|
+
end
|
1721
|
+
|
1722
|
+
def successful?
|
1723
|
+
@mutex.synchronize do
|
1724
|
+
@successful
|
1725
|
+
end
|
1726
|
+
end
|
1727
|
+
|
1728
|
+
def failed?
|
1729
|
+
@mutex.synchronize do
|
1730
|
+
not @successful
|
1731
|
+
end
|
1732
|
+
end
|
1733
|
+
end
|
1734
|
+
end
|
1735
|
+
|
1736
|
+
class Command
|
1737
|
+
class << self
|
1738
|
+
attr_reader :registry
|
1739
|
+
end
|
1740
|
+
|
1741
|
+
@registry = {}
|
1742
|
+
|
1743
|
+
def self.register(klass)
|
1744
|
+
if BoPeep::Command.registry.key?(klass.registry_name)
|
1745
|
+
# TODO: include debug information in the warning
|
1746
|
+
$stderr.puts "[WARN] command with the name `#{klass.command_name}' already exists " \
|
1747
|
+
"and is being replaced"
|
1748
|
+
end
|
1749
|
+
|
1750
|
+
BoPeep::Command.registry[klass.registry_name] = klass
|
1751
|
+
end
|
1752
|
+
|
1753
|
+
def self.inherited(klass)
|
1754
|
+
register(klass)
|
1755
|
+
end
|
1756
|
+
|
1757
|
+
def self.find(argv)
|
1758
|
+
klass = nil
|
1759
|
+
|
1760
|
+
argv.each do |arg|
|
1761
|
+
if @registry.key?(arg)
|
1762
|
+
klass = @registry.fetch(arg)
|
1763
|
+
end
|
1764
|
+
end
|
1765
|
+
|
1766
|
+
klass
|
1767
|
+
end
|
1768
|
+
|
1769
|
+
class Name
|
1770
|
+
def self.from_relative_script_path(path)
|
1771
|
+
script_name = path.to_s.chomp File.extname(path)
|
1772
|
+
|
1773
|
+
new(script_name: script_name.tr("_", "-"))
|
1774
|
+
end
|
1775
|
+
|
1776
|
+
attr_reader :cli
|
1777
|
+
attr_reader :object
|
1778
|
+
attr_reader :script
|
1779
|
+
|
1780
|
+
def initialize(class_name: nil, script_name: nil)
|
1781
|
+
if class_name.nil? && script_name.nil?
|
1782
|
+
raise ArgumentError, "`script_name' or `class_name' must be specified"
|
1783
|
+
end
|
1784
|
+
|
1785
|
+
if class_name
|
1786
|
+
@object = class_name
|
1787
|
+
|
1788
|
+
@script = class_name.gsub("::", "/")
|
1789
|
+
@script.gsub!(/([A-Z\d]+)([A-Z][a-z])/, '\1-\2')
|
1790
|
+
@script.gsub!(/([a-z\d])([A-Z])/, '\1-\2')
|
1791
|
+
@script.downcase!
|
1792
|
+
elsif script_name
|
1793
|
+
@script = script_name
|
1794
|
+
|
1795
|
+
@object = script_name.gsub(/[a-z\d]*/) { |match| match.capitalize }
|
1796
|
+
@object.gsub!(/(?:_|-|(\/))([a-z\d]*)/i) { "#{$1}#{$2.capitalize}" }
|
1797
|
+
@object.gsub!("/", "::")
|
1798
|
+
end
|
1799
|
+
|
1800
|
+
@cli = @script.tr("/", ":")
|
1801
|
+
end
|
1802
|
+
|
1803
|
+
def console
|
1804
|
+
"[#{cli}]"
|
1805
|
+
end
|
1806
|
+
|
1807
|
+
def registry
|
1808
|
+
@cli
|
1809
|
+
end
|
1810
|
+
|
1811
|
+
def to_s
|
1812
|
+
@cli.dup
|
1813
|
+
end
|
1814
|
+
end
|
1815
|
+
|
1816
|
+
module Naming
|
1817
|
+
def self.extended(klass)
|
1818
|
+
BoPeep::Command.register(klass)
|
1819
|
+
end
|
1820
|
+
|
1821
|
+
def command_name
|
1822
|
+
if defined?(@command_name)
|
1823
|
+
@command_name
|
1824
|
+
else
|
1825
|
+
@command_name = Name.new(class_name: name)
|
1826
|
+
end
|
1827
|
+
end
|
1828
|
+
|
1829
|
+
def registry_name
|
1830
|
+
command_name.registry
|
1831
|
+
end
|
1832
|
+
end
|
1833
|
+
|
1834
|
+
module CommandMissingHook
|
1835
|
+
def const_missing(class_name)
|
1836
|
+
loaded = false
|
1837
|
+
|
1838
|
+
command_name = Name.new(class_name: class_name.to_s)
|
1839
|
+
path = "#{command_name.script.tr("-", "_")}.rb"
|
1840
|
+
|
1841
|
+
BoPeep.search_paths.each do |bopeep_path|
|
1842
|
+
command_path = bopeep_path.join("commands", path)
|
1843
|
+
|
1844
|
+
if command_path.exist?
|
1845
|
+
require command_path
|
1846
|
+
loaded = true
|
1847
|
+
break
|
1848
|
+
end
|
1849
|
+
end
|
1850
|
+
|
1851
|
+
if loaded
|
1852
|
+
Object.const_get(class_name)
|
1853
|
+
else
|
1854
|
+
super
|
1855
|
+
end
|
1856
|
+
end
|
1857
|
+
end
|
1858
|
+
|
1859
|
+
module Chaining
|
1860
|
+
def |(other)
|
1861
|
+
ChainCommand.new(self, other)
|
1862
|
+
end
|
1863
|
+
end
|
1864
|
+
|
1865
|
+
module BehaviorWithoutRegistration
|
1866
|
+
def self.included(klass)
|
1867
|
+
klass.extend(ClassMethods)
|
1868
|
+
end
|
1869
|
+
|
1870
|
+
module ClassMethods
|
1871
|
+
include Naming
|
1872
|
+
include Chaining
|
1873
|
+
include CommandMissingHook
|
1874
|
+
|
1875
|
+
def call(context)
|
1876
|
+
command = new(context)
|
1877
|
+
command.call
|
1878
|
+
command
|
1879
|
+
end
|
1880
|
+
end
|
1881
|
+
|
1882
|
+
attr_reader :argv
|
1883
|
+
attr_reader :context
|
1884
|
+
attr_reader :hosts
|
1885
|
+
attr_reader :parser
|
1886
|
+
attr_reader :playlist
|
1887
|
+
|
1888
|
+
def initialize(context)
|
1889
|
+
@context = context
|
1890
|
+
|
1891
|
+
@parser = @context.parser
|
1892
|
+
@hosts = @context.hosts
|
1893
|
+
@argv = @context.argv.dup
|
1894
|
+
@playlist = @context.playlist
|
1895
|
+
|
1896
|
+
configure_parser!
|
1897
|
+
end
|
1898
|
+
|
1899
|
+
def name
|
1900
|
+
command_name.cli
|
1901
|
+
end
|
1902
|
+
|
1903
|
+
def call
|
1904
|
+
run
|
1905
|
+
self
|
1906
|
+
end
|
1907
|
+
|
1908
|
+
def run
|
1909
|
+
end
|
1910
|
+
|
1911
|
+
def command_name
|
1912
|
+
self.class.command_name
|
1913
|
+
end
|
1914
|
+
|
1915
|
+
def |(other)
|
1916
|
+
other.(context)
|
1917
|
+
end
|
1918
|
+
|
1919
|
+
def chain(*others)
|
1920
|
+
others.inject(self) do |execution, command|
|
1921
|
+
execution | command
|
1922
|
+
end
|
1923
|
+
end
|
1924
|
+
|
1925
|
+
def on_failure(execution_result)
|
1926
|
+
exit 1
|
1927
|
+
end
|
1928
|
+
|
1929
|
+
def SGR(text)
|
1930
|
+
Console::SGR(text)
|
1931
|
+
end
|
1932
|
+
|
1933
|
+
def and_then(order: :parallel, &block)
|
1934
|
+
@playlist.queue Step.callback(self, @playlist.insert_at, order, block)
|
1935
|
+
end
|
1936
|
+
|
1937
|
+
private
|
1938
|
+
|
1939
|
+
def configure_parser!
|
1940
|
+
end
|
1941
|
+
|
1942
|
+
def run_script(script_name = nil, on: hosts, order: :parallel, &setup)
|
1943
|
+
script_name ||= command_name.script
|
1944
|
+
script = Script.new(script_name, context)
|
1945
|
+
|
1946
|
+
queue Step.new(self, script, on, order, &setup)
|
1947
|
+
end
|
1948
|
+
|
1949
|
+
def run_command(command, on: hosts, order: :parallel, &setup)
|
1950
|
+
queue Step.new(self, command, on, order, &setup)
|
1951
|
+
end
|
1952
|
+
|
1953
|
+
def on_localhost(banner, &block)
|
1954
|
+
queue Step.local(self, banner, block)
|
1955
|
+
end
|
1956
|
+
alias locally on_localhost
|
1957
|
+
|
1958
|
+
def queue(step)
|
1959
|
+
@playlist.queue(step)
|
1960
|
+
step
|
1961
|
+
end
|
1962
|
+
end
|
1963
|
+
|
1964
|
+
include BehaviorWithoutRegistration
|
1965
|
+
|
1966
|
+
module Behavior
|
1967
|
+
def self.included(klass)
|
1968
|
+
klass.send(:include, BehaviorWithoutRegistration)
|
1969
|
+
Command.register(klass)
|
1970
|
+
end
|
1971
|
+
|
1972
|
+
def self.extended(klass)
|
1973
|
+
klass.extend(CommandMissingHook)
|
1974
|
+
klass.extend(Naming)
|
1975
|
+
klass.extend(Chaining)
|
1976
|
+
end
|
1977
|
+
end
|
1978
|
+
|
1979
|
+
class Step
|
1980
|
+
def self.local(command, banner, block)
|
1981
|
+
new(command, BlockProxy.new(banner, block), LOCALHOST, :serial)
|
1982
|
+
end
|
1983
|
+
|
1984
|
+
def self.callback(command, last_step, order, block)
|
1985
|
+
new \
|
1986
|
+
command,
|
1987
|
+
Callback.new(last_step, block),
|
1988
|
+
last_step.hosts,
|
1989
|
+
order
|
1990
|
+
end
|
1991
|
+
|
1992
|
+
attr_reader :command
|
1993
|
+
attr_reader :hosts
|
1994
|
+
attr_accessor :next
|
1995
|
+
attr_accessor :previous
|
1996
|
+
attr_reader :result
|
1997
|
+
|
1998
|
+
def initialize(command, run_object, hosts, order, &setup)
|
1999
|
+
@command = command
|
2000
|
+
@run_object = run_object
|
2001
|
+
@hosts = hosts
|
2002
|
+
@order = order
|
2003
|
+
@setup = setup
|
2004
|
+
end
|
2005
|
+
|
2006
|
+
def banner
|
2007
|
+
@run_object
|
2008
|
+
end
|
2009
|
+
|
2010
|
+
def and_then(order: :parallel, &block)
|
2011
|
+
@command.and_then order: order, &block
|
2012
|
+
end
|
2013
|
+
|
2014
|
+
def run
|
2015
|
+
@result = case @run_object
|
2016
|
+
when String
|
2017
|
+
@hosts.run_command(@run_object, order: @order, &@setup)
|
2018
|
+
when Script
|
2019
|
+
@hosts.run_script(@run_object, order: @order, &@setup)
|
2020
|
+
when Callback
|
2021
|
+
@run_object.run(@order)
|
2022
|
+
when BlockProxy
|
2023
|
+
# NOTE: A `Proc` means `@hosts` is `LOCALHOST`
|
2024
|
+
Executor::Result.new.tap do |result|
|
2025
|
+
result.update LOCALHOST.run(&@run_object)
|
2026
|
+
end
|
2027
|
+
end
|
2028
|
+
end
|
2029
|
+
|
2030
|
+
class BlockProxy < Struct.new(:banner, :block)
|
2031
|
+
def to_s
|
2032
|
+
banner
|
2033
|
+
end
|
2034
|
+
|
2035
|
+
def to_proc
|
2036
|
+
block
|
2037
|
+
end
|
2038
|
+
end
|
2039
|
+
|
2040
|
+
class Callback
|
2041
|
+
def initialize(last_step, run_object)
|
2042
|
+
@last_step = last_step
|
2043
|
+
@run_object = run_object
|
2044
|
+
end
|
2045
|
+
|
2046
|
+
def run(order)
|
2047
|
+
Executor.for(order).new(@last_step.result).run do |last_outcome, result|
|
2048
|
+
callback_outcome = Outcome.new(last_outcome)
|
2049
|
+
|
2050
|
+
begin
|
2051
|
+
# Use `||=` in case `#value` is set in the callback
|
2052
|
+
callback_outcome.value ||= @run_object.(last_outcome.host, last_outcome.value, callback_outcome)
|
2053
|
+
rescue => error
|
2054
|
+
callback_outcome.error = error
|
2055
|
+
end
|
2056
|
+
|
2057
|
+
result.update callback_outcome
|
2058
|
+
end
|
2059
|
+
end
|
2060
|
+
|
2061
|
+
def to_s
|
2062
|
+
"(callback)"
|
2063
|
+
end
|
2064
|
+
|
2065
|
+
class Outcome
|
2066
|
+
attr_reader :error
|
2067
|
+
attr_reader :host
|
2068
|
+
attr_reader :last_outcome
|
2069
|
+
attr_accessor :value
|
2070
|
+
|
2071
|
+
def initialize(outcome)
|
2072
|
+
@last_outcome = outcome
|
2073
|
+
@host = @last_outcome.host
|
2074
|
+
@successful = true
|
2075
|
+
end
|
2076
|
+
|
2077
|
+
def resolve(value)
|
2078
|
+
@value = value
|
2079
|
+
end
|
2080
|
+
|
2081
|
+
def fail!(message)
|
2082
|
+
@successful = false
|
2083
|
+
|
2084
|
+
raise CallbackFailedError.new(message)
|
2085
|
+
end
|
2086
|
+
|
2087
|
+
def error=(error)
|
2088
|
+
@successful = false
|
2089
|
+
@error = error
|
2090
|
+
end
|
2091
|
+
|
2092
|
+
def successful?
|
2093
|
+
@successful
|
2094
|
+
end
|
2095
|
+
|
2096
|
+
def failed?
|
2097
|
+
!successful?
|
2098
|
+
end
|
2099
|
+
|
2100
|
+
def failure_reason
|
2101
|
+
error
|
2102
|
+
end
|
2103
|
+
|
2104
|
+
def failure_summary
|
2105
|
+
error && error.full_message
|
2106
|
+
end
|
2107
|
+
end
|
2108
|
+
end
|
2109
|
+
|
2110
|
+
class CallbackFailedError < StandardError
|
2111
|
+
end
|
2112
|
+
end
|
2113
|
+
end
|
2114
|
+
|
2115
|
+
class ChainCommand
|
2116
|
+
def initialize(left, right)
|
2117
|
+
@left = left
|
2118
|
+
@right = right
|
2119
|
+
end
|
2120
|
+
|
2121
|
+
def call(context)
|
2122
|
+
@left.(context) | @right
|
2123
|
+
end
|
2124
|
+
|
2125
|
+
def |(other)
|
2126
|
+
ChainCommand.new(self, other)
|
2127
|
+
end
|
2128
|
+
end
|
2129
|
+
|
2130
|
+
class Script
|
2131
|
+
class Template
|
2132
|
+
INTERPOLATION_REGEXP = /%{([\w:]+)}/
|
2133
|
+
|
2134
|
+
def self.find(relative_script_path, fail_if_missing: true)
|
2135
|
+
extension = File.extname(relative_script_path)
|
2136
|
+
relative_paths = [relative_script_path]
|
2137
|
+
|
2138
|
+
if extension.empty?
|
2139
|
+
relative_paths << "#{relative_script_path}.sh"
|
2140
|
+
else
|
2141
|
+
relative_paths << relative_script_path.chomp(extension)
|
2142
|
+
end
|
2143
|
+
|
2144
|
+
paths_checked = []
|
2145
|
+
|
2146
|
+
script_path = BoPeep.search_paths.inject(nil) do |path, directory|
|
2147
|
+
relative_paths.each do |relative_path|
|
2148
|
+
target_path = directory.join("scripts", relative_path)
|
2149
|
+
paths_checked << target_path
|
2150
|
+
|
2151
|
+
if target_path.exist?
|
2152
|
+
path = target_path
|
2153
|
+
end
|
2154
|
+
end
|
2155
|
+
|
2156
|
+
if path
|
2157
|
+
break path
|
2158
|
+
end
|
2159
|
+
end
|
2160
|
+
|
2161
|
+
if script_path
|
2162
|
+
new(script_path)
|
2163
|
+
elsif fail_if_missing
|
2164
|
+
raise <<~ERROR
|
2165
|
+
ScriptNotFoundError: Could not find `#{relative_script_path}'
|
2166
|
+
Looked in:
|
2167
|
+
#{paths_checked.join("\n")}
|
2168
|
+
ERROR
|
2169
|
+
else
|
2170
|
+
MISSING
|
2171
|
+
end
|
2172
|
+
end
|
2173
|
+
|
2174
|
+
class Missing
|
2175
|
+
def compile(*)
|
2176
|
+
"".freeze
|
2177
|
+
end
|
2178
|
+
end
|
2179
|
+
|
2180
|
+
MISSING = Missing.new
|
2181
|
+
|
2182
|
+
attr_reader :content
|
2183
|
+
|
2184
|
+
def initialize(file_path)
|
2185
|
+
@path = file_path
|
2186
|
+
@content = @path.read
|
2187
|
+
end
|
2188
|
+
|
2189
|
+
def compile(interpolations)
|
2190
|
+
@content.gsub(INTERPOLATION_REGEXP) do |match|
|
2191
|
+
if interpolations.key?($1)
|
2192
|
+
interpolations[$1]
|
2193
|
+
else
|
2194
|
+
$stderr.puts "[WARN] `#{$1}' is not defined in interpolations or context variables"
|
2195
|
+
$stderr.puts "[WARN] leaving interpolation `#{match}' as is"
|
2196
|
+
match
|
2197
|
+
end
|
2198
|
+
end
|
2199
|
+
end
|
2200
|
+
end
|
2201
|
+
|
2202
|
+
REMOTE_DIRECTORY = Pathname.new("$HOME").join("bopeep", "scripts")
|
2203
|
+
|
2204
|
+
class Interpolations
|
2205
|
+
CONTEXT_REGEXP = /variables:(\w+)/
|
2206
|
+
|
2207
|
+
def initialize(context)
|
2208
|
+
@context = context
|
2209
|
+
@values = {}
|
2210
|
+
end
|
2211
|
+
|
2212
|
+
def key?(key)
|
2213
|
+
if key =~ CONTEXT_REGEXP
|
2214
|
+
@context.variables_set_defined?($1)
|
2215
|
+
else
|
2216
|
+
@values.key?(key)
|
2217
|
+
end
|
2218
|
+
end
|
2219
|
+
|
2220
|
+
def [](key)
|
2221
|
+
if @values.key?(key)
|
2222
|
+
@values[key]
|
2223
|
+
elsif key =~ CONTEXT_REGEXP && @context.variables_set_defined?($1)
|
2224
|
+
variables = @context[$1]
|
2225
|
+
content = variables.to_h.sort.map { |key, value| %{#{key}="#{value}"} }.join("\n")
|
2226
|
+
|
2227
|
+
@values[key] = content
|
2228
|
+
end
|
2229
|
+
end
|
2230
|
+
|
2231
|
+
def []=(key, value)
|
2232
|
+
@values[key] = value
|
2233
|
+
end
|
2234
|
+
end
|
2235
|
+
|
2236
|
+
attr_reader :content
|
2237
|
+
attr_reader :name
|
2238
|
+
attr_reader :remote_path
|
2239
|
+
|
2240
|
+
def initialize(script_name, context, defaults_path: nil)
|
2241
|
+
command_name = Command::Name.from_relative_script_path(script_name)
|
2242
|
+
@name = command_name.script
|
2243
|
+
@context = context
|
2244
|
+
|
2245
|
+
local_path = Pathname.new(@name)
|
2246
|
+
|
2247
|
+
# FIXME: search parent directories for a defaults script
|
2248
|
+
defaults_path ||= File.join(local_path.dirname, "_defaults")
|
2249
|
+
@defaults = Template.find(defaults_path, fail_if_missing: false)
|
2250
|
+
|
2251
|
+
@template = Template.find(local_path.to_s)
|
2252
|
+
|
2253
|
+
@remote_path = REMOTE_DIRECTORY.join(local_path)
|
2254
|
+
end
|
2255
|
+
|
2256
|
+
def content
|
2257
|
+
interpolations = Interpolations.new(@context)
|
2258
|
+
interpolations["script_name"] = name
|
2259
|
+
interpolations["script_defaults"] = @defaults.compile(interpolations).chomp
|
2260
|
+
|
2261
|
+
@template.compile(interpolations)
|
2262
|
+
end
|
2263
|
+
|
2264
|
+
def install_directory
|
2265
|
+
@remote_path.dirname
|
2266
|
+
end
|
2267
|
+
|
2268
|
+
def install_path
|
2269
|
+
@remote_path.relative_path_from Pathname.new("$HOME")
|
2270
|
+
end
|
2271
|
+
|
2272
|
+
def to_s
|
2273
|
+
name.dup
|
2274
|
+
end
|
2275
|
+
end
|
2276
|
+
|
2277
|
+
class << self
|
2278
|
+
attr_reader :config
|
2279
|
+
attr_reader :console
|
2280
|
+
attr_reader :search_paths
|
2281
|
+
end
|
2282
|
+
|
2283
|
+
@config = Config.new
|
2284
|
+
@console = Console.new
|
2285
|
+
@search_paths = []
|
2286
|
+
|
2287
|
+
def self.configure
|
2288
|
+
yield config
|
2289
|
+
end
|
2290
|
+
|
2291
|
+
def self.default_user
|
2292
|
+
config.default_user
|
2293
|
+
end
|
2294
|
+
end
|