warg 0.0.1 → 0.1.0

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