warg 0.0.1 → 0.1.0

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