blower 3.4 → 4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
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: