bopeep 0.1.6

Sign up to get free protection for your applications and to get access to all the features.
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