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 +5 -5
- data/README.md +1 -29
- data/bin/console-playground +124 -0
- data/bin/hodor +7 -0
- data/lib/warg.rb +2205 -2
- metadata +92 -27
- data/.gitignore +0 -17
- data/Gemfile +0 -4
- data/LICENSE.txt +0 -22
- data/Rakefile +0 -1
- data/lib/warg/version.rb +0 -3
- data/warg.gemspec +0 -23
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
|
-
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: 1dfc90bc5fc1c52e7eac6a1f96968c0fbeed6d18313e35264a025af656dcad8a
|
4
|
+
data.tar.gz: 2cb29f9dc1e49fb87a4b905c052c198de601952d7cf2c9ab39be44a2623d26b8
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 7248cd6836ae24f89e3ccb06d27ad26c273a37a2f6600562ae8d226d2348bc5ec85e67d6899218ea9448a18dde4315d5f966315a046c72cbb9c07e7138a8d206
|
7
|
+
data.tar.gz: 505dc011e56a496e75ad07de1e203592d050792f54992e77c6c908fad8bee9a4c60cc1343b26830992574536b1c84a30513364372896a3cd50601d0aa2aa44be
|
data/README.md
CHANGED
@@ -1,29 +1 @@
|
|
1
|
-
#
|
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
data/lib/warg.rb
CHANGED
@@ -1,5 +1,2208 @@
|
|
1
|
-
require "
|
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
|
-
|
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
|