blower 3.4 → 4.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
2
  SHA1:
3
- metadata.gz: e2038dcb8532a933ded5b0e986f5c219a4d5749b
4
- data.tar.gz: cf04e56b0a72200cfeceefeecbd4b2688e0f8b17
3
+ metadata.gz: 347f6e63c590bd170c7e942416850924fd0a10f9
4
+ data.tar.gz: 8389ddabdcc2e0bb4144806433b82b9a0a1f3eb0
5
5
  SHA512:
6
- metadata.gz: 1de9c6423f7b5e3692c46b49ea69801a080ff8c770a49d5add4b447c1cee61fdb8a49f35ee1357651dd07ba524d8795898bc37a3c1b44fac33c5ed523569991d
7
- data.tar.gz: a4bd0455c23e6ae2024d92e02f8ca958a139f1c2965b5f5dc8c66135ff72b23abdae682f0a113ee4c6c7f30e4fb265853c53b10e72c32dfa73cc3c721c9f88c5
6
+ metadata.gz: 5dfd5196f69a28330ab5b560c3c2a94379b3c6aabedabe81e17aacfd27ffec190bd62bedd33aaa0969f379f61a6c34324fac39903b52465aa43970e9be3ceb39
7
+ data.tar.gz: 9e0c0317df8fecb554f59a088d5d298ee4025678434f0feea924cb66d81f9f94b34b0c6775ba3371e4b29e2aa8e404af24b38a58059d1891c512fc5ac2d6d10a
data/bin/blow CHANGED
@@ -3,7 +3,7 @@ require 'blower'
3
3
  require 'pathname'
4
4
  require 'optparse'
5
5
 
6
- $LOGLEVEL = :info
6
+ path = []
7
7
 
8
8
  OptionParser.new do |opts|
9
9
  opts.banner = "Usage: blow [options] task..."
@@ -11,18 +11,27 @@ OptionParser.new do |opts|
11
11
  Dir.chdir v
12
12
  end
13
13
  opts.on "-l LEVEL", "Minimal log level" do |v|
14
- $LOGLEVEL = v.downcase.to_sym
14
+ Blower::Logger.level = v.downcase.to_sym
15
15
  end
16
16
  opts.on "-I DIR", "Add directory to search path" do |v|
17
- $LOGLEVEL = v.downcase.to_sym
17
+ path << v
18
18
  end
19
19
  opts.on "-v", "Show version and exit" do |v|
20
- puts Blower::VERSION
20
+ puts "blow (blower) #{Blower::VERSION}"
21
21
  exit
22
22
  end
23
23
  end.order!
24
24
 
25
- context = Blower::Context.new([".", Dir.pwd])
25
+ $: << File.join(Dir.pwd, "lib")
26
+
27
+ path.unshift Dir.pwd
28
+
29
+ context = Blower::Context.new(path)
30
+
31
+ if File.directory?(File.join(Dir.pwd, "lib"))
32
+ context.path << File.join(Dir.pwd, "lib")
33
+ end
34
+
26
35
  context.run "Blowfile", optional: true
27
36
  begin
28
37
  until ARGV.empty?
data/lib/blower.rb CHANGED
@@ -1,4 +1,11 @@
1
+
2
+ # Top-level module for all things blower.
3
+ module Blower
4
+ end
5
+
6
+ require 'blower/util'
1
7
  require 'blower/logger'
8
+ require 'blower/errors'
2
9
  require 'blower/context'
3
10
  require 'blower/host'
4
11
  require 'blower/version'
@@ -1,251 +1,306 @@
1
1
  require 'erb'
2
+ require 'json'
2
3
  require 'forwardable'
3
4
 
4
5
  module Blower
5
6
 
6
7
  # Blower tasks are executed within a context.
7
8
  #
8
- # The context can be used to share information between tasks, by storing it in instance variables.
9
+ # The context can be used to share information between tasks by storing it in instance variables.
9
10
  #
10
11
  class Context
11
12
  extend Forwardable
12
13
 
14
+ # YARD documentation macros
15
+
16
+ # @macro [new] onable
17
+ # @param [Array] on The hosts to operate on. Defaults to the context's current host list.
18
+
19
+ # @macro [new] asable
20
+ # @param [#to_s] as The remote user to operate as. Defaults to the context's current user if that is not nil, and the host's configured user otherwise.
21
+
22
+ # @macro [new] quietable
23
+ # @param [Boolean] quiet Whether to suppress log messages.
24
+
25
+ # @macro [new] onceable
26
+ # @param [String] once If non-nil, only perform the operation if it hasn't been done before as per #once.
27
+
13
28
  # Search path for tasks.
14
29
  attr_accessor :path
15
30
 
16
- # The currently running task.
17
- attr_accessor :task
18
-
19
31
  # The target hosts.
20
32
  attr_accessor :hosts
21
33
 
34
+ # Username override. If not-nil, this user is used for all remote accesses.
35
+ attr_accessor :user
36
+
37
+ # The file name of the currently running task.
38
+ # Context#cp interprets relative file names relative to this file name's directory component.
39
+ attr_accessor :file
40
+
41
+ # Create a new Context.
42
+ # @param [Array] path The search path for tasks.
22
43
  def initialize (path)
23
44
  @path = path
24
- @hosts = []
25
- @have_seen = {}
26
45
  @data = {}
27
- @basename = "blow"
46
+ @hosts = []
28
47
  end
29
48
 
30
- def set (hash, &block)
31
- if block
32
- begin
33
- old_data, @data = @data, @data.dup
34
- set(hash)
35
- block.()
36
- ensure
37
- @data = old_data
38
- end
39
- else
40
- @data.merge! hash
41
- hash.each do |var, val|
42
- define_singleton_method(var) { @data[var] }
43
- end
44
- end
49
+ # Return a context variable.
50
+ # @param name The name of the variable.
51
+ # @param default The value to return if the variable is not set.
52
+ def get (name, default = nil)
53
+ @data.fetch(name, default)
45
54
  end
46
55
 
47
- def get (var)
48
- @data[var]
56
+ # Remove context variables
57
+ # @param names The names to remove from the variables.
58
+ def unset (*names)
59
+ names.each do |name|
60
+ @data.delete name
61
+ end
49
62
  end
50
63
 
51
- def log
52
- Logger.instance
64
+ # Merge the hash into the context variables.
65
+ # @param [Hash] hash The values to merge into the context variables.
66
+ def set (hash)
67
+ @data.merge! hash
53
68
  end
54
69
 
55
- def add_host (spec)
56
- host = Host.new(*spec) unless spec.is_a?(Host)
57
- @hosts << host
58
- host
70
+ # Yield with the hash temporary merged into the context variables.
71
+ # Only variables specifically named in +hash+ will be reset when the yield returns.
72
+ # @param [Hash] hash The values to merge into the context variables.
73
+ # @return Whatever the yielded-to block returns.
74
+ # @macro quietable
75
+ def with (hash, quiet: false)
76
+ old_values = data.values_at(hash.keys)
77
+ log.debug "with #{hash}", quiet: quiet do
78
+ set hash
79
+ yield
80
+ end
81
+ ensure
82
+ hash.keys.each.with_index do |key, i|
83
+ @data[key] = old_values[i]
84
+ end
59
85
  end
60
86
 
61
- # Execute the block once for each host and return nil.
62
- def each (hosts = @hosts, &block)
63
- Kernel.fail "No hosts left" if hosts.empty?
64
- hosts.each(&block)
87
+ # Yield with a temporary host list
88
+ # @macro quietable
89
+ def on (*hosts, quiet: false)
90
+ let :@hosts => hosts.flatten do
91
+ log.info "on #{@hosts.map(&:name).join(", ")}", quiet: quiet do
92
+ yield
93
+ end
94
+ end
65
95
  end
66
96
 
67
- # Execute the block once for each host and return nil.
68
- def with (hosts = @hosts, &block)
69
- ctx = clone
70
- ctx.hosts = hosts.map do |spec|
71
- spec.is_a?(Host) ? spec : Host.new(*spec)
97
+ # Yield with a temporary username override
98
+ # @macro quietable
99
+ def as (user, quiet: false)
100
+ let :@user => user do
101
+ log.info "as #{user}", quiet: quiet do
102
+ yield
103
+ end
72
104
  end
73
- ctx.instance_exec(&block)
74
105
  end
75
106
 
76
- def with_each (hosts = @hosts, &block)
77
- each do |host|
78
- with host, &block
107
+ # Find and execute a task. For each entry in the search path, it checks for +path/task.rb+, +path/task/blow.rb+,
108
+ # and finally for bare +path/task+. The search stops at the first match.
109
+ # If found, the task is executed within the context, with +@file+ bound to the task's file name.
110
+ # @param [String] task The name of the task.
111
+ # @macro quietable
112
+ # @macro onceable
113
+ # @raise [TaskNotFound] If no task is found.
114
+ # @raise Whatever the task itself raises.
115
+ # @return The result of evaluating the task file.
116
+ def run (task, optional: false, quiet: false, once: nil)
117
+ once once, quiet: quiet do
118
+ log.info "run #{task}", quiet: quiet do
119
+ file = find_task(task)
120
+ code = File.read(file)
121
+ let :@file => file do
122
+ instance_eval(code, file)
123
+ end
124
+ end
79
125
  end
126
+ rescue TaskNotFound => e
127
+ return if optional
128
+ raise e
80
129
  end
81
130
 
82
- # Execute the block once for each host and return a hash of the results.
83
- def hashmap (hosts = @hosts, &block)
84
- result = {}
85
- threads = []
86
- log.nest do
87
- each hosts do |host|
88
- threads << Thread.new do
89
- result[host] = block.(host)
131
+ # Execute a shell command on each host
132
+ # @macro onable
133
+ # @macro asable
134
+ # @macro quietable
135
+ # @macro onceable
136
+ def sh (command, as: user, on: hosts, quiet: false, once: nil)
137
+ self.once once, quiet: quiet do
138
+ log.info "sh #{command}", quiet: quiet do
139
+ hash_map(hosts) do |host|
140
+ host.sh command, as: as, quiet: quiet
90
141
  end
91
142
  end
92
- threads.each(&:join)
93
143
  end
94
- result
95
144
  end
96
145
 
97
- # Reboot each host and waits for them to come back up.
98
- # @param command The reboot command. A string.
99
- def reboot (command = "reboot")
100
- if f = get(:sh_command)
101
- command = f.(command)
102
- end
103
- each do |host|
104
- log.info "rebooting: #{host.name}"
105
- begin
106
- host.sh command, user: @user
107
- rescue IOError
108
- end
109
- log.debug "rebooting: #{host.name}: waiting for shutdown..."
110
- sleep 0.1 while host.ping
111
- end
112
- each do |host|
113
- log.debug "rebooting: #{host.name}: waiting for return..."
114
- sleep 1.0 until host.ping
115
- end
116
- end
117
-
118
- # Execute a shell command on each host.
119
- def sh (command, quiet: false)
120
- log.info "sh: #{command}" unless quiet
121
- if f = get(:sh_command)
122
- command = f.(command)
123
- end
124
- win = true
125
- hashmap do |host|
126
- out = ""
127
- status = host.sh(command, out, user: @user)
128
- if status != 0
129
- fail host, "#{command}: exit status #{status}"
130
- nil
131
- else
132
- out
146
+ # Copy a file or readable to the host filesystems.
147
+ # @overload cp(readable, to, as: user, on: hosts, quiet: false)
148
+ # @param [#read] from An object from which to read the contents of the new file.
149
+ # @param [String] to The file name to write the string to.
150
+ # @macro onable
151
+ # @macro asable
152
+ # @macro quietable
153
+ # @macro onceable
154
+ # @overload cp(filename, to, as: user, on: hosts, quiet: false)
155
+ # @param [String] from The name of the local file to copy.
156
+ # @param [String] to The file name to write the string to.
157
+ # @macro onable
158
+ # @macro asable
159
+ # @macro quietable
160
+ # @macro onceable
161
+ def cp (from, to, as: user, on: hosts, quiet: false, once: nil)
162
+ self.once once, quiet: quiet do
163
+ log.info "cp: #{from} -> #{to}", quiet: quiet do
164
+ Dir.chdir File.dirname(file) do
165
+ hash_map(hosts) do |host|
166
+ host.cp from, to, as: as, quiet: quiet
167
+ end
168
+ end
133
169
  end
134
170
  end
135
171
  end
136
172
 
137
- # Execute a command on the remote host.
138
- # @return false if the command exits with a non-zero status
139
- def sh? (command, quiet: false)
140
- log.info "sh?: #{command}" unless quiet
141
- if f = get(:sh_command)
142
- command = f.(command)
143
- end
144
- hashmap do |host|
145
- host.sh(command, user: @user) == 0
173
+ # Reads a remote file from each host.
174
+ # @param [String] filename The file to read.
175
+ # @return [Hash] A hash of +Host+ objects to +Strings+ of the file contents.
176
+ # @macro onable
177
+ # @macro asable
178
+ # @macro quietable
179
+ def read (filename, as: user, on: hosts, quiet: false)
180
+ log.info "read: #{filename}", quiet: quiet do
181
+ hash_map(hosts) do |host|
182
+ host.read filename, as: as
183
+ end
146
184
  end
147
185
  end
148
186
 
149
- # Execute a command on the remote host.
150
- # @return false if the command exits with a non-zero status
151
- def ping (quiet: false, **args)
152
- log.debug "ping" unless quiet
153
- win = true
154
- each do |host|
155
- win &&= host.ping(**args)
187
+ # Writes a string to a file on the host filesystems.
188
+ # @param [String] string The string to write.
189
+ # @param [String] to The file name to write the string to.
190
+ # @macro onable
191
+ # @macro asable
192
+ # @macro quietable
193
+ # @macro onceable
194
+ def write (string, to, as: user, on: hosts, quiet: false, once: nil)
195
+ self.once once, quiet: quiet do
196
+ log.info "write: #{string.bytesize} bytes -> #{to}", quiet: quiet do
197
+ hash_map(hosts) do |host|
198
+ host.write string, to, as: as, quiet: quiet
199
+ end
200
+ end
156
201
  end
157
- win
158
202
  end
159
203
 
160
- def fail (host, message)
161
- @hosts -= [host]
204
+ # Renders and installs files from ERB templates. Files are under +from+ in ERB format. +from/foo/bar.conf.erb+ is
205
+ # rendered and written to +to/foo/bar.conf+. Non-ERB files are ignored.
206
+ # @param [String] from The directory to search for .erb files.
207
+ # @param [String] to The remote directory to put files in.
208
+ # @macro onable
209
+ # @macro asable
210
+ # @macro quietable
211
+ # @macro onceable
212
+ def render (from, to, as: user, on: hosts, quiet: false, once: nil)
213
+ self.once once, quiet: quiet do
214
+ Dir.chdir File.dirname(file) do
215
+ (Dir["#{from}**/*.erb"] + Dir["#{from}**/.*.erb"]).each do |path|
216
+ template = ERB.new(File.read(path))
217
+ to_path = to + path[from.length..-5]
218
+ log.info "render: #{path} -> #{to_path}", quiet: quiet do
219
+ hash_map(hosts) do |host|
220
+ host.cp StringIO.new(template.result(binding)), to_path, as: as, quiet: quiet
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
162
226
  end
163
227
 
164
- def rebase_path (path)
165
- path.gsub(/^(?!\/|.\/)/, File.dirname(@file) + "/")
228
+ # Ping each host by trying to connect to port 22
229
+ # @macro onable
230
+ # @macro quietable
231
+ def ping (on: hosts, quiet: false)
232
+ log.info "ping", quiet: quiet do
233
+ hash_map(hosts) do |host|
234
+ host.ping
235
+ end
236
+ end
166
237
  end
167
238
 
168
- # Copy a file or readable to the host filesystem.
169
- # @param from An object that responds to read, or a string which names a file, or an array of either.
170
- # @param to A string.
171
- def cp (from, to)
172
- log.info "cp: #{from} -> #{to}"
173
- from = rebase_path(from)
174
- each do |host|
175
- host.cp(from, to, rsync_command: get(:rsync_command))
239
+ # Execute a block only once per host.
240
+ # It is usually preferable to make tasks idempotent, but when that isn't
241
+ # possible, +once+ will only execute the block on hosts where a block
242
+ # with the same key hasn't previously been successfully executed.
243
+ # @param [String] key Uniquely identifies the block.
244
+ # @param [String] store File to store +once+'s state in.
245
+ # @macro quietable
246
+ def once (key, store: "/var/cache/blower.json", quiet: false)
247
+ return yield unless key
248
+ log.info "once: #{key}", quiet: quiet do
249
+ hash_map(hosts) do |host|
250
+ done = begin
251
+ JSON.parse(host.read(store, quiet: true))
252
+ rescue => e
253
+ {}
254
+ end
255
+ unless done[key]
256
+ on [host] do
257
+ yield
258
+ end
259
+ done[key] = true
260
+ host.write(done.to_json, store, quiet: true)
261
+ end
262
+ end
176
263
  end
177
264
  end
178
265
 
179
- def as (user)
180
- old_user, @user = @user, user
181
- yield
182
- ensure
183
- @user = old_user
184
- end
185
-
186
- # Writes a string to a file on the host filesystem.
187
- # @param string The string to write.
188
- # @param to A string.
189
- def write (string, to)
190
- log.info "write: -> #{to}"
191
- each do |host|
192
- host.cp(StringIO.new(string), to, user: @user, sh_command: get(:sh_command))
193
- end
194
- end
195
-
196
- def render (from, to)
197
- from = rebase_path(from)
198
- (Dir["#{from}**/*.erb"] + Dir["#{from}**/.*.erb"]).each do |path|
199
- template = ERB.new(File.read(path))
200
- to_path = to + path[from.length..-5]
201
- log.info "render: #{path} -> #{to_path}"
202
- each do |host|
203
- host.cp StringIO.new(template.result(binding)), to_path, user: @user, sh_command: get(:sh_command)
204
- end
205
- end
206
- end
207
-
208
- # Run a task.
209
- # @param task (String) The name of the task
210
- def run (task, optional: false)
211
- files = []
212
- @path.each do |dir|
213
- name = File.join(dir, task)
214
- name += ".rb" unless File.exist?(name)
215
- if File.directory?(name)
216
- dirtask = File.join(name, @basename)
217
- dirtask += ".rb" unless File.exist?(dirtask)
218
- name = dirtask
219
- blowfile = File.join(name, "Blowfile")
220
- files << blowfile if File.exist?(blowfile) && !@have_seen[blowfile]
266
+ private
267
+
268
+ def hash_map (hosts = self.hosts)
269
+ {}.tap do |result|
270
+ each(hosts) do |host|
271
+ result[host] = yield(host)
221
272
  end
222
- files << name if File.exist?(name)
223
- break unless files.empty?
224
- end
225
- if files.empty?
226
- if optional
227
- return
228
- else
229
- raise "can't find #{task}"
273
+ end
274
+ end
275
+
276
+ def each (hosts = self.hosts)
277
+ fail "No hosts" if hosts.empty?
278
+ [hosts].flatten.each do |host|
279
+ begin
280
+ yield host
281
+ rescue => e
282
+ host.log.error e.message
283
+ hosts.delete host
230
284
  end
231
- else
232
- log.debug "Running #{task}" do
233
- begin
234
- old_task, @task = @task, task
235
- files.each do |file|
236
- @have_seen[file] = true
237
- begin
238
- old_file, @file = @file, file
239
- instance_eval(File.read(file), file)
240
- ensure
241
- @file = old_file
242
- end
243
- end
244
- ensure
245
- @task = old_task
246
- end
285
+ end
286
+ fail "No hosts remaining" if hosts.empty?
287
+ end
288
+
289
+ def find_task (name)
290
+ log.debug "Searching for task #{name}" do
291
+ path.each do |path|
292
+ log.trace "checking #{File.join(path, name + ".rb")}"
293
+ file = File.join(path, name + ".rb")
294
+ return file if File.exists?(file)
295
+ log.trace "checking #{File.join(path, name, "/blow.rb")}"
296
+ file = File.join(path, name, "/blow.rb")
297
+ return file if File.exists?(file)
298
+ log.trace "checking #{File.join(path, name)}"
299
+ file = File.join(path, name)
300
+ return file if File.exists?(file)
247
301
  end
248
302
  end
303
+ fail TaskNotFound, "Task not found: #{name}"
249
304
  end
250
305
 
251
306
  end
@@ -0,0 +1,17 @@
1
+
2
+ module Blower
3
+
4
+ # Raised when a task isn't found.
5
+ class TaskNotFound < RuntimeError; end
6
+
7
+ # Raised when a command returns a non-zero exit status.
8
+ class FailedCommand < RuntimeError; end
9
+
10
+ class ExecuteError < RuntimeError
11
+ attr_accessor :status
12
+ def initialize (status)
13
+ @status = status
14
+ end
15
+ end
16
+
17
+ end
data/lib/blower/host.rb CHANGED
@@ -10,46 +10,56 @@ module Blower
10
10
  include MonitorMixin
11
11
  extend Forwardable
12
12
 
13
- attr_accessor :name
13
+ # The default remote user.
14
14
  attr_accessor :user
15
+
16
+ # The host adress.
15
17
  attr_accessor :address
16
18
 
17
19
  def_delegators :data, :[], :[]=
18
20
 
19
- class ExecuteError < Exception
20
- attr_accessor :status
21
- def initialize (status)
22
- @status = status
23
- end
24
- end
25
-
26
- def initialize (name, data: {}, user: "root")
27
- @address = @name = name
21
+ def initialize (address, data: {}, user: "root")
22
+ @address = address
28
23
  @user = user
29
24
  @data = data
30
25
  super()
31
26
  end
32
27
 
33
- def ping (retries: 0)
28
+ def name
29
+ to_s
30
+ end
31
+
32
+ # Represent the host as a string.
33
+ def to_s
34
+ "#{@user}@#{@address}"
35
+ end
36
+
37
+ # Attempt to connect to port 22 on the host.
38
+ # @return +true+
39
+ # @raise If it doesn't connect within 1 second.
40
+ # @api private
41
+ def ping ()
42
+ log.debug "Pinging"
34
43
  Timeout.timeout(1) do
35
44
  TCPSocket.new(address, 22).close
36
45
  end
37
46
  true
38
47
  rescue Timeout::Error, Errno::ECONNREFUSED
39
- return false if retries == 0
40
- retries -= 1
41
- retry
48
+ fail "Failed to ping #{self}"
42
49
  end
43
50
 
44
- def cp (froms, to, output = "", user: nil, rsync_command: nil, sh_command: nil)
51
+ # Copy files or directories to the host.
52
+ # @api private
53
+ def cp (froms, to, as: nil, quiet: false)
54
+ as ||= @user
55
+ output = ""
45
56
  synchronize do
46
57
  [froms].flatten.each do |from|
47
58
  if from.is_a?(String)
48
59
  to += "/" if to[-1] != "/" && from.is_a?(Array)
49
60
  command = ["rsync", "-e", "ssh -oStrictHostKeyChecking=no", "-r"]
50
- command += ["--rsync-path=#{rsync_command.()}"] if rsync_command
51
- command += [*from, "#{user || @user}@#{@address}:#{to}"]
52
- log.trace command.shelljoin
61
+ command += [*from, "#{as}@#{@address}:#{to}"]
62
+ log.trace command.shelljoin, quiet: quiet
53
63
  IO.popen(command, in: :close, err: %i(child out)) do |io|
54
64
  until io.eof?
55
65
  begin
@@ -61,15 +71,14 @@ module Blower
61
71
  end
62
72
  io.close
63
73
  if !$?.success?
64
- log.fatal "exit status #{$?.exitstatus}: #{command}"
65
- log.fatal output
74
+ log.fatal "exit status #{$?.exitstatus}: #{command}", quiet: quiet
75
+ log.fatal output, quiet: quiet
66
76
  fail "failed to copy files"
67
77
  end
68
78
  end
69
79
  elsif from.respond_to?(:read)
70
80
  cmd = "echo #{from.read.shellescape} > #{to.shellescape}"
71
- cmd = sh_command.(cmd) if sh_command
72
- sh cmd
81
+ sh cmd, quiet: quiet
73
82
  else
74
83
  fail "Don't know how to copy a #{from.class}: #{from}"
75
84
  end
@@ -78,61 +87,70 @@ module Blower
78
87
  true
79
88
  end
80
89
 
81
- def sh (command, output = "", user: nil)
90
+ # Write a string to a host file.
91
+ # @api private
92
+ def write (string, to, as: nil, quiet: false)
93
+ cp StringIO.new(string), to, as: as, quiet: quiet
94
+ end
95
+
96
+ # Read a host file.
97
+ # @api private
98
+ def read (filename, as: nil, quiet: false)
99
+ sh "cat #{filename.shellescape}", as: as, quiet: quiet
100
+ end
101
+
102
+ # Execute a command on the host and return its output.
103
+ # @api private
104
+ def sh (command, as: nil, quiet: false)
105
+ as ||= @user
106
+ output = ""
82
107
  synchronize do
83
- log.debug command
108
+ log.debug "sh #{command}", quiet: quiet
84
109
  result = nil
85
- ch = ssh(user || @user).open_channel do |ch|
110
+ ch = ssh(as).open_channel do |ch|
86
111
  ch.request_pty do |ch, success|
87
112
  "failed to acquire pty" unless success unless success
88
113
  ch.exec(command) do |_, success|
89
114
  fail "failed to execute command" unless success
90
115
  ch.on_data do |_, data|
91
- log.trace "received #{data.bytesize} bytes stdout"
116
+ log.trace "received #{data.bytesize} bytes stdout", quiet: quiet
92
117
  output << data
93
118
  end
94
119
  ch.on_extended_data do |_, _, data|
95
- log.trace "received #{data.bytesize} bytes stderr"
120
+ log.trace "received #{data.bytesize} bytes stderr", quiet: quiet
96
121
  output << data.colorize(:red)
97
122
  end
98
123
  ch.on_request("exit-status") do |_, data|
99
124
  result = data.read_long
100
- log.trace "received exit-status #{result}"
125
+ log.trace "received exit-status #{result}", quiet: quiet
101
126
  end
102
127
  end
103
128
  end
104
129
  end
105
130
  ch.wait
106
- if result != 0
107
- log.fatal "exit status #{result}: #{command}"
108
- log.fatal output
109
- end
110
- result
131
+ fail FailedCommand, output if result != 0
132
+ output
111
133
  end
112
134
  end
113
135
 
114
- # # Execute the block with self as a parameter.
115
- # # Exists to conform with the HostGroup interface.
116
- # def each (&block)
117
- # block.(self)
118
- # end
136
+ # Produce a Logger prefixed with the host name.
137
+ # @api private
138
+ def log
139
+ @log ||= Logger.instance.with_prefix("on #{self}: ")
140
+ end
119
141
 
120
142
  private
121
143
 
122
144
  attr_accessor :data
123
145
 
124
- def log
125
- Logger.instance.with_prefix("(on #{name})")
126
- end
127
-
128
146
  def ssh (user)
129
147
  @sessions ||= {}
130
148
  if @sessions[user] && @sessions[user].closed?
131
- log.trace "Discovered the connection to ssh:#{user}@#{name} was lost"
149
+ log.warn "Discovered the connection to ssh:#{self} was lost"
132
150
  @sessions[user] = nil
133
151
  end
134
152
  @sessions[user] ||= begin
135
- log.trace "Connecting to ssh:#{user}@#{name}"
153
+ log.debug "Connecting to ssh:#{self}"
136
154
  Net::SSH.start(address, user)
137
155
  end
138
156
  end
data/lib/blower/logger.rb CHANGED
@@ -12,92 +12,94 @@ module Blower
12
12
  include MonitorMixin
13
13
  include Singleton
14
14
 
15
+ # Logging levels in ascending order of severity.
16
+ LEVELS = %i(all trace debug info warn error fatal none)
17
+
18
+ # Colorize specifications for log levels.
15
19
  COLORS = {
16
- trace: {color: :light_black},
17
- debug: {color: :default},
18
- info: {color: :blue},
19
- warn: {color: :yellow},
20
- error: {color: :red},
21
- fatal: {color: :light_white, background: :red},
20
+ trace: { color: :light_black },
21
+ debug: { color: :default },
22
+ info: { color: :blue },
23
+ warn: { color: :yellow },
24
+ error: { color: :red },
25
+ fatal: { color: :light_white, background: :red },
22
26
  }
23
27
 
24
- RANKS = {
25
- all: 100,
26
- trace: 60,
27
- debug: 50,
28
- info: 40,
29
- warn: 30,
30
- error: 20,
31
- fatal: 10,
32
- off: 0,
33
- }
28
+ class << self
29
+ # The minimum severity level for which messages will be displayed.
30
+ attr_accessor :level
31
+
32
+ # The current indentation level.
33
+ attr_accessor :indent
34
+ end
35
+
36
+ self.indent = 0
34
37
 
35
- @@indent = 0
38
+ self.level = :info
36
39
 
37
- def initialize (prefix = nil)
40
+ def initialize (prefix = "")
38
41
  @prefix = prefix
39
42
  super()
40
43
  end
41
44
 
45
+ # Return a logger with the specified prefix
42
46
  def with_prefix (string)
43
- self.class.send(:new, "#{@prefix}#{string}")
47
+ Logger.send(:new, string)
44
48
  end
45
49
 
46
- # Log a trace level event
47
- def trace (a=nil, *b, &c); log(a, :trace, *b, &c); end
48
-
49
- # Log a debug level event
50
- def debug (a=nil, *b, &c); log(a, :debug, *b, &c); end
51
-
52
- # Log a info level event
53
- def info (a=nil, *b, &c); log(a, :info, *b, &c); end
54
-
55
- # Log a warn level event
56
- def warn (a=nil, *b, &c); log(a, :warn, *b, &c); end
57
-
58
- # Log a error level event
59
- def error (a=nil, *b, &c); log(a, :error, *b, &c); end
60
-
61
- # Log a fatal level event
62
- def fatal (a=nil, *b, &c); log(a, :fatal, *b, &c); end
63
-
64
- def nest (&c)
65
- log(nil, nil, &c)
50
+ # Yield with a temporarily incremented indent counter
51
+ def with_indent ()
52
+ Logger.indent += 1
53
+ yield
54
+ ensure
55
+ Logger.indent -= 1
66
56
  end
67
57
 
68
- def task (message, &block)
69
- STDOUT.write " " * @@indent + (@prefix ? @prefix + " " : "") + message + "... "
70
- begin
71
- @@indent += 1
72
- block.() if block
73
- ensure
74
- @@indent -= 1
58
+ # Display a log message. The block, if specified, is executed in an indented region after the log message is shown.
59
+ # @api private
60
+ # @param [Symbol] level the severity level
61
+ # @param [#to_s] message the message to display
62
+ # @param block a block to execute with an indent after the message is displayed
63
+ # @return the value of block, or nil
64
+ def log (level, message, quiet: false, &block)
65
+ if !quiet && (LEVELS.index(level) >= LEVELS.index(Logger.level))
66
+ synchronize do
67
+ message = message.to_s.colorize(COLORS[level]) if level
68
+ message.split("\n").each do |line|
69
+ STDERR.puts " " * Logger.indent + @prefix + line
70
+ end
71
+ end
72
+ with_indent(&block) if block
73
+ elsif block
74
+ block.()
75
75
  end
76
- puts "OK".colorize({color: :green})
77
- rescue => e
78
- puts "ERROR".colorize({color: :red})
79
- raise e
80
76
  end
81
77
 
82
- private
83
-
84
- def log (message = nil, level = :info, &block)
85
- if message && (level.nil? || RANKS[level] <= RANKS[$LOGLEVEL])
86
- Logger.instance.synchronize do
87
- message = message.colorize(COLORS[level]) if level
88
- puts " " * @@indent + (@prefix ? @prefix + " " : "") + message
89
- end
90
- begin
91
- @@indent += 1
92
- block.() if block
93
- ensure
94
- @@indent -= 1
95
- end
96
- else
97
- block.() if block
78
+ # Define a helper method for a given severity level.
79
+ # @!macro [attach] log_helper
80
+ # @!method $1(message, &block)
81
+ # Display a $1 log message, as if by calling log directly.
82
+ # @param [#to_s] message the message to display
83
+ # @param block a block to execute with an indent after the message is displayed
84
+ # @return the value of block, or nil
85
+ def self.define_helper (level)
86
+ define_method(level) do |*args, **kwargs, &block|
87
+ log(level, *args, **kwargs, &block)
98
88
  end
99
89
  end
100
90
 
91
+ define_helper :trace
92
+ define_helper :debug
93
+ define_helper :info
94
+ define_helper :warn
95
+ define_helper :error
96
+ define_helper :fatal
97
+
101
98
  end
102
99
 
103
100
  end
101
+
102
+ # Return the logger instance.
103
+ def log
104
+ Blower::Logger.instance
105
+ end
@@ -0,0 +1,28 @@
1
+
2
+ class Object
3
+
4
+ # Yield with temporary instance variable assignments.
5
+ # @param [Hash] bindings A hash of instance variable names to their temporary values.
6
+ # @return Whatever yielding returns
7
+ # @example Time
8
+ # @foo = 1
9
+ # let :@foo => 2 do
10
+ # puts @foo #=> outputs 2
11
+ # end
12
+ # puts @foo #=> outputs 1
13
+ def let (bindings)
14
+ old_values = bindings.keys.map do |key|
15
+ instance_variable_get(key)
16
+ end
17
+ bindings.each do |key, value|
18
+ instance_variable_set(key, value)
19
+ end
20
+ yield
21
+ ensure
22
+ return unless old_values
23
+ bindings.keys.each.with_index do |key, i|
24
+ instance_variable_set(key, old_values[i])
25
+ end
26
+ end
27
+
28
+ end
@@ -1,3 +1,3 @@
1
1
  module Blower
2
- VERSION = "3.4"
2
+ VERSION = "4.0"
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: blower
3
3
  version: !ruby/object:Gem::Version
4
- version: '3.4'
4
+ version: '4.0'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Nathan Baum
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2016-05-25 00:00:00.000000000 Z
11
+ date: 2016-06-13 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: net-ssh
@@ -76,8 +76,10 @@ files:
76
76
  - bin/blow
77
77
  - lib/blower.rb
78
78
  - lib/blower/context.rb
79
+ - lib/blower/errors.rb
79
80
  - lib/blower/host.rb
80
81
  - lib/blower/logger.rb
82
+ - lib/blower/util.rb
81
83
  - lib/blower/version.rb
82
84
  homepage: http://www.github.org/nbaum/blower
83
85
  licenses: