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.
Files changed (6) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +1 -0
  3. data/bin/bopeep +7 -0
  4. data/bin/console-playground +123 -0
  5. data/lib/bopeep.rb +2294 -0
  6. 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