scout_agent 3.0.6 → 3.0.7

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.
@@ -0,0 +1,366 @@
1
+ #!/usr/bin/env ruby -wKU
2
+ # encoding: UTF-8
3
+
4
+ # require standard libraries
5
+ require "open-uri"
6
+ require "yaml"
7
+ require "io/wait"
8
+
9
+ # load agent extensions
10
+ require "scout_agent/mission"
11
+
12
+ # shut off SSL verification by open-uri
13
+ module OpenSSL::SSL
14
+ remove_const(:VERIFY_PEER)
15
+ VERIFY_PEER = OpenSSL::SSL::VERIFY_NONE
16
+ end
17
+
18
+ module ScoutAgent
19
+ class Assignment
20
+ #
21
+ # Invoke with:
22
+ #
23
+ # scout_agent test [once] path/to/plugin.rb [<<< '{"options": "in JSON"}']
24
+ #
25
+ # This command is a test mode for plugin developers. It allows you to run a
26
+ # plugin and see what that code generates.
27
+ #
28
+ # The only required argument is a path to the plugin you wish to test. That
29
+ # path can be a file path or a URL to load the code from.
30
+ #
31
+ # By default, URL's will just be run once and files will be run in a loop
32
+ # that watches the code for changes and reruns until you control-c the
33
+ # process. You can add the once parameter to have a file treated as a URL.
34
+ #
35
+ # Default options will be read from a matching .yml file at the same path,
36
+ # if available. You can also provide option settings on <tt>$stdin</tt>
37
+ # which will override the defaults.
38
+ #
39
+ # If you need to test a plugin that expects queued messages, pass a
40
+ # <tt>"__queue__"</tt> option that's an Array of messages.
41
+ # They will be delivered to your plugin.
42
+ #
43
+ class Test < Assignment
44
+ # Runs the test command.
45
+ def execute
46
+ install_interrupt_handler
47
+ prepare_log
48
+ parse_options
49
+ prepare_mission
50
+ load_code
51
+ load_options
52
+ test_mission
53
+ end
54
+
55
+ #######
56
+ private
57
+ #######
58
+
59
+ ############
60
+ ### Test ###
61
+ ############
62
+
63
+ # Install a nice shutdown that doesn't include an Exception backtrace.
64
+ def install_interrupt_handler
65
+ trap("INT") do
66
+ Thread.new do
67
+ @log.info("Stopping.") if @log
68
+ exit
69
+ end
70
+ end
71
+ end
72
+
73
+ # Builds a simple log to <tt>$stdout</tt> that shows all messages.
74
+ def prepare_log
75
+ @log = WireTap.new($stdout)
76
+ @log.progname = :test
77
+ end
78
+
79
+ # Pulls out the optional run-once and the required path options.
80
+ def parse_options
81
+ args = Array(other_args)
82
+
83
+ # check for once option
84
+ @once = false
85
+ @url_mode = false
86
+ if args.first == "once"
87
+ @log.info("Starting single plugin run.")
88
+ @once = args.shift
89
+ elsif args.first.to_s =~ %r{\A[A-Za-z][-A-Za-z0-9+.]*://}
90
+ @log.info("Testing plugin from a URL.")
91
+ @once = @url_mode = true
92
+ else
93
+ @log.info( "Starting plugin development mode. " +
94
+ "Watching code for changes." )
95
+ end
96
+
97
+ # get the code path
98
+ unless @mission_path = args.shift
99
+ abort_with_missing_path
100
+ end
101
+ end
102
+
103
+ #
104
+ # Build the mission details that would normally have come from the
105
+ # database.
106
+ #
107
+ def prepare_mission
108
+ @mission = { :id => :testing,
109
+ :name => File.basename(@mission_path, ".rb"),
110
+ :last_run_at => nil,
111
+ :memory => Hash.new,
112
+ :options => Hash.new }
113
+ end
114
+
115
+ # Reads the code from disk or URL, loading it into the mission details.
116
+ def load_code
117
+ if @url_mode or ( File.exist?(@mission_path) and
118
+ File.file?(@mission_path) and
119
+ File.readable?(@mission_path) )
120
+ begin
121
+ @mission[:code] = read_file_or_url(@mission_path)
122
+ rescue Exception # reading error
123
+ abort_with_unreadable(@mission_path)
124
+ end
125
+ else
126
+ abort_with_unreadable(@mission_path)
127
+ end
128
+ end
129
+
130
+ #
131
+ # Reads the options first from the defaults file and then as overrides
132
+ # from <tt>$stdin</tt>.
133
+ #
134
+ def load_options
135
+ # look for default options in the standard place
136
+ @options_path = @mission_path.sub(/\.rb/i, ".yml")
137
+ if @url_mode or ( File.exist?(@options_path) and
138
+ File.file?(@options_path) and
139
+ File.readable?(@options_path) )
140
+ @log.info("Loading default options from '#{@options_path}'.")
141
+ begin
142
+ defaults = YAML.load(read_file_or_url(@options_path))
143
+ rescue Exception => error
144
+ if @url_mode
145
+ @log.warn("Options not found at '#{@options_path}'.")
146
+ else
147
+ case error
148
+ when ArgumentError # invalid YAML
149
+ abort_with_bad_defaults
150
+ else # reading error
151
+ abort_with_unreadable(@options_path)
152
+ end
153
+ end
154
+ end
155
+ if defaults.is_a?(Hash) and defaults["options"].is_a?(Hash)
156
+ defaults["options"].each do |name, details|
157
+ if details.is_a? Hash
158
+ @mission[:options].merge!(name => details["default"])
159
+ end
160
+ end
161
+ end
162
+ else
163
+ @log.warn("Options file '#{@options_path}' not found.")
164
+ end
165
+
166
+ # also check for overrides
167
+ if defined?(@overrides) or $stdin.ready?
168
+ @log.info("Reading and setting option overrides.")
169
+ begin
170
+ @overrides ||= JSON.parse($stdin.read)
171
+ rescue JSON::ParserError
172
+ abort_with_bad_overrides
173
+ end
174
+ if @overrides.is_a? Hash
175
+ @mission[:options].merge!(@overrides)
176
+ end
177
+ end
178
+ end
179
+
180
+ #
181
+ # The main testing event loop for a plugin. This controls execution,
182
+ # waiting through the run, updating time and memory, and waiting for
183
+ # code changes.
184
+ #
185
+ def test_mission
186
+ unless @once
187
+ code_last_modified = File.stat(@mission_path).mtime
188
+ options_last_modified = File.stat(@options_path).mtime rescue nil
189
+ end
190
+ loop do
191
+ # fork off the plugin process
192
+ @reader, @writer = IO.pipe
193
+ mission_pid = fork do
194
+ reset_environment
195
+ compile_mission
196
+ run_mission
197
+ complete_mission
198
+ end
199
+
200
+ # ensure the child process exits in reasonable time
201
+ @writer.close
202
+ exit_status = nil
203
+ begin
204
+ Timeout.timeout(60) do
205
+ exit_status = Process.wait2(mission_pid).last
206
+ end
207
+ rescue Timeout::Error # mission exceeded allowed execution
208
+ exit_status = Process.term_or_kill(mission_pid)
209
+ @log.error( "#{@mission[:name]} took too long to run: " +
210
+ "#{exit_status && exit_status.exitstatus}." )
211
+ end
212
+
213
+ # stop there if this is a one shot
214
+ break if @once
215
+
216
+ # update mission details
217
+ @mission[:last_run_at] = Time.now
218
+ if exit_status and exit_status.success?
219
+ begin
220
+ @mission[:memory] = JSON.parse(@reader.read)
221
+ @log.debug("Plugin memory updated to %p." % @mission[:memory])
222
+ rescue Exception # bad memory transfer
223
+ @log.error("Memory could not be updated.")
224
+ # do nothng: keeping old memory
225
+ end
226
+ end
227
+
228
+ # reload the code when it changes
229
+ @log.info("Watching plugin code for changes.")
230
+ loop do
231
+ modified = false
232
+ code_modified = File.stat(@mission_path).mtime
233
+ if code_modified > code_last_modified
234
+ code_last_modified = code_modified
235
+ load_code
236
+ modified = true
237
+ end
238
+ options_modified = File.stat(@options_path).mtime rescue nil
239
+ if (not options_last_modified and options_modified) or
240
+ ( options_last_modified and
241
+ options_modified and
242
+ options_modified > options_last_modified )
243
+ options_last_modified = options_modified
244
+ load_options
245
+ modified = true
246
+ end
247
+ break if modified
248
+ sleep 1
249
+ end
250
+ end
251
+ end
252
+
253
+ ###############
254
+ ### Helpers ###
255
+ ###############
256
+
257
+ # Loads data from a file or URL +path+, without OpenSSL warnings.
258
+ def read_file_or_url(path)
259
+ no_warnings { open(path) { |data| data.read } }
260
+ end
261
+
262
+ ###############
263
+ ### Mission ###
264
+ ###############
265
+
266
+ # Resets the log process name and closes have of the memory transfer pipe.
267
+ def reset_environment
268
+ @log = WireTap.new($stdout)
269
+ @log.progname = :mission
270
+
271
+ @reader.close
272
+ end
273
+
274
+ # Compiles the mission code without generating database errors.
275
+ def compile_mission
276
+ @log.info("Compiling #{@mission[:name]} mission.")
277
+ begin
278
+ eval(@mission[:code], TOPLEVEL_BINDING, @mission[:name])
279
+ rescue Exception => error # any compile error
280
+ raise if $!.is_a? SystemExit # don't catch exit() calls
281
+ @log.error( "#{@mission[:name]} could not be compiled: " +
282
+ "#{error.message} (#{error.class})." )
283
+ exit(2) # warn parent that we failed to compile
284
+ end
285
+ end
286
+
287
+ # Executes the mission without generating database errors.
288
+ def run_mission
289
+ @log.info("Preparing #{@mission[:name]} mission.")
290
+ if prepared = Mission.prepared
291
+ @log.info("Starting #{@mission[:name]} mission.")
292
+ @mission[:object] =
293
+ prepared.new( *( @mission.values_at( :id,
294
+ :name,
295
+ :last_run_at,
296
+ :memory,
297
+ :options ) + [@log] ) )
298
+ @mission[:object].run
299
+ else # no mission loaded
300
+ @log.error("#{@mission[:name]} could not be prepared.")
301
+ exit(3) # warn parent that we couldn't prpare mission
302
+ end
303
+ end
304
+
305
+ # Transfers mission memory back to the testing process.
306
+ def complete_mission
307
+ @log.info("#{@mission[:name]} mission complete.")
308
+ @writer.puts @mission[:object].data_for_server[:memory].to_json
309
+ end
310
+
311
+ ##############
312
+ ### Errors ###
313
+ ##############
314
+
315
+ #
316
+ # Abort with an error message to the user that explains that the path or
317
+ # URL is a required argument.
318
+ #
319
+ def abort_with_missing_path
320
+ abort <<-END_MISSING_PATH.trim
321
+
322
+ You must provide a path to your code on the file system or a URL to
323
+ fetch it from:
324
+
325
+ scout_agent test PATH_OR_URL
326
+
327
+ END_MISSING_PATH
328
+ end
329
+
330
+ #
331
+ # Abort with an error message to the user that says we can't parse the
332
+ # provided JSON.
333
+ #
334
+ def abort_with_unreadable(file)
335
+ abort "\nFailed to read from '#{file}'."
336
+ end
337
+
338
+ #
339
+ # Abort with an error message to the user that says we can't parse the
340
+ # default options YAML file.
341
+ #
342
+ def abort_with_bad_defaults
343
+ abort <<-END_BAD_DEFAULTS.trim
344
+
345
+ Failed to parse option defaults. You must provide default options in
346
+ Scout's standard YAML format.
347
+ END_BAD_DEFAULTS
348
+ end
349
+
350
+ #
351
+ # Abort with an error message to the user that says we can't parse the
352
+ # provided JSON.
353
+ #
354
+ def abort_with_bad_overrides
355
+ abort <<-END_BAD_OVERRIDES.trim
356
+
357
+ Failed to parse option overrides. You must provide options in valid
358
+ JSON. For example:
359
+
360
+ scout_agent test … <<< '{"field": "setting", "num": 42}'
361
+
362
+ END_BAD_OVERRIDES
363
+ end
364
+ end
365
+ end
366
+ end
@@ -3,7 +3,19 @@
3
3
 
4
4
  module ScoutAgent
5
5
  class Assignment
6
+ #
7
+ # Invoke with:
8
+ #
9
+ # sudo scout_agent update
10
+ #
11
+ # This command rewrites the current configuration file. As such, it has two
12
+ # main uses. First, you can run this command to load the current file, pass
13
+ # some switches to change settings, and it will save the changes like a text
14
+ # editor would. It's also useful when an updated version of the agent
15
+ # includes new options you would like to have added to your existing file.
16
+ #
6
17
  class Update < Assignment
18
+ # Runs the update command.
7
19
  def execute
8
20
  begin
9
21
  Plan.config_file.unlink
@@ -22,6 +34,14 @@ module ScoutAgent
22
34
  end
23
35
  end
24
36
 
37
+ #######
38
+ private
39
+ #######
40
+
41
+ #
42
+ # Abort with an error message to the user that says we don't have enough
43
+ # permission to rewrite the configuration file.
44
+ #
25
45
  def abort_with_sudo_required
26
46
  abort <<-END_SUDO_REQUIRED.trim
27
47
  I don't have enough privileges to update your identity and
@@ -6,18 +6,34 @@ require "tempfile"
6
6
 
7
7
  module ScoutAgent
8
8
  class Assignment
9
+ #
10
+ # Invoke with:
11
+ #
12
+ # scout_agent upload_log [FILE_DATE]
13
+ #
14
+ # This command can help us troubleshoot problems with your agent. You can
15
+ # invoke this to pass a log file up to our servers, so we can look through
16
+ # it for problems. Note that this is never done automatically. You must
17
+ # invoke this command manually.
18
+ #
19
+ # The +FILE_DATE+ is just the date of the log file in the form YYYY-MM-DD.
20
+ #
9
21
  class UploadLog < Assignment
22
+ # Runs the upload_log command.
10
23
  def execute
24
+ # build the log file name
11
25
  file_name = "#{ScoutAgent.agent_name}.log"
12
26
  if date = Array(other_args).shift
13
27
  file_name += ".#{date.delete('^0-9')}"
14
28
  end
15
29
  log_file = Plan.log_dir + file_name
16
30
 
31
+ # ensure it exists
17
32
  unless log_file.exist?
18
33
  abort_with_not_found(file_name)
19
34
  end
20
35
 
36
+ # copy the log to a zipped temporary file
21
37
  puts "Preparing file for the server. This may take a moment..."
22
38
  begin
23
39
  upload_file = Tempfile.new("#{file_name}.gz")
@@ -32,7 +48,7 @@ module ScoutAgent
32
48
  end
33
49
  puts "Done."
34
50
 
35
-
51
+ # upload the zipped temporary file to the server
36
52
  puts "Sending file to the server. This may take a moment..."
37
53
  server = Server.new
38
54
  if server.post_log(upload_file)
@@ -42,18 +58,32 @@ module ScoutAgent
42
58
  end
43
59
  end
44
60
 
61
+ #######
45
62
  private
63
+ #######
46
64
 
65
+ #
66
+ # Abort with an error message to the user that says we can't find the
67
+ # indicated or default log file.
68
+ #
47
69
  def abort_with_not_found(log_file_name)
48
70
  abort "'#{log_file_name}' could not be found to upload."
49
71
  end
50
72
 
73
+ #
74
+ # Abort with an error message to the user that says why we can't prepare
75
+ # the file for upload.
76
+ #
51
77
  def abort_with_preparation_error(error)
52
78
  abort <<-END_PREPARATION_ERROR
53
79
  Could not prepare file for upload: #{error.message} (#{error.class})
54
80
  END_PREPARATION_ERROR
55
81
  end
56
82
 
83
+ #
84
+ # Abort with an error message to the user that says we received a server
85
+ # error when we attempted to upload.
86
+ #
57
87
  def abort_with_server_error
58
88
  abort "A networking error prevented the file from being sent."
59
89
  end
@@ -2,19 +2,62 @@
2
2
  # encoding: UTF-8
3
3
 
4
4
  module ScoutAgent
5
+ #
6
+ # An Assignment is a command that can be given to the agent on the
7
+ # command-line. This object encapsulates the series of steps needed to invoke
8
+ # a command and subclasses supply the specific behavior.
9
+ #
5
10
  class Assignment
6
- { :plan => :file_and_switches,
7
- :choose_user => false,
8
- :choose_group => false }.each do |name, default|
9
- instance_eval <<-END_CONFIG
10
- def #{name}(setting = nil)
11
- @#{name} ||= #{default.inspect} # default
12
- @#{name} = setting if setting # writer
13
- @#{name} # reader
11
+ #
12
+ # This method is a factory for one method class attributes supporting
13
+ # getting (with a default) and setting.
14
+ #
15
+ def self.get_or_set(name, default, setting = nil)
16
+ var = "@#{name}"
17
+ if setting
18
+ instance_variable_set(var, setting) # writer
19
+ elsif not instance_variable_defined? var
20
+ instance_variable_set(var, default) # default
14
21
  end
15
- END_CONFIG
22
+ instance_variable_get(var) # reader
16
23
  end
24
+ private_class_method :get_or_set
17
25
 
26
+ #
27
+ # If the +setting+ of this attribute includes the word "file", the
28
+ # configuration file will be loaded before the command is invoked.
29
+ # Similarly, the word "switches" will cause the configuration to be updated
30
+ # using the provided command-line switches. The default setting is
31
+ # <tt>:file_and_switches</tt>.
32
+ #
33
+ def self.plan(setting = nil)
34
+ get_or_set(:plan, :file_and_switches, setting)
35
+ end
36
+
37
+ #
38
+ # If +setting+ is +true+, a user to operate as will be selected based on the
39
+ # configuration. Note that a user is just selected and it's up to the
40
+ # command to make use of it. That user is available to commands via the
41
+ # user() method.
42
+ #
43
+ def self.choose_user(setting = nil)
44
+ get_or_set(:choose_user, false, setting)
45
+ end
46
+
47
+ #
48
+ # If +setting+ is +true+, a group to operate as will be selected based on
49
+ # the configuration. Note that a group is just selected and it's up to the
50
+ # command to make use of it. That group is available to commands via the
51
+ # group() method.
52
+ #
53
+ def self.choose_group(setting = nil)
54
+ get_or_set(:choose_group, false, setting)
55
+ end
56
+
57
+ #
58
+ # Builds a new Assignment with the passed +switches+ and +other_args+. The
59
+ # Dispatcher usess this to prepare a command for running.
60
+ #
18
61
  def initialize(switches, other_args)
19
62
  @switches = switches
20
63
  @other_args = other_args
@@ -24,16 +67,35 @@ module ScoutAgent
24
67
 
25
68
  include Tracked
26
69
 
27
- attr_reader :switches, :other_args, :user, :group
70
+ # The command-line switches passed to the command.
71
+ attr_reader :switches
72
+ # Any non-switch arguments passed to the command.
73
+ attr_reader :other_args
74
+ # The user to operate as, if selected. See choose_user() for details.
75
+ attr_reader :user
76
+ # The group to operate as, if selected. See choose_group() for details.
77
+ attr_reader :group
28
78
 
79
+ #
80
+ # Loads configuaration as directed by plan(), selects indentity as directed
81
+ # by choose_user() and choose_group(), then calls execute() for the command.
82
+ # Subclasses provide the execute() method to provide their specific
83
+ # behavior. The Dispatcher calls this method to launch a command.
84
+ #
29
85
  def prepare_and_execute
30
86
  read_the_plan
31
87
  choose_identity
32
88
  execute
33
89
  end
34
90
 
91
+ #######
35
92
  private
93
+ #######
36
94
 
95
+ #
96
+ # Reads the configuration file and/or updates the plan from the provided
97
+ # command-line switches. See the plan() method for details.
98
+ #
37
99
  def read_the_plan
38
100
  if self.class.plan.to_s.include? "file"
39
101
  begin
@@ -47,6 +109,10 @@ module ScoutAgent
47
109
  end
48
110
  end
49
111
 
112
+ #
113
+ # Selects a user and/or group as dictated by choose_user() and
114
+ # choose_group(). See those methods for details.
115
+ #
50
116
  def choose_identity
51
117
  [ %w[user getpwnam],
52
118
  %w[group getgrnam] ].each do |type, interface|
@@ -69,20 +135,29 @@ module ScoutAgent
69
135
  end
70
136
  end
71
137
 
138
+ #
139
+ # Attempts to fetch a plan from the server as a way to test connectivity and
140
+ # the agent settings. A status line will be printed to <tt>$stdout</tt>
141
+ # unless +quiet+ is +true+.
142
+ #
72
143
  def test_server_connection(quiet = false)
73
144
  unless quiet
74
- print "Testing server connection: "
145
+ $stdout.print "Testing server connection: "
75
146
  $stdout.flush
76
147
  end
77
148
  if Server.new.get_plan
78
- puts "success." unless quiet
149
+ $stdout.puts "success." unless quiet
79
150
  true
80
151
  else
81
- puts "failed." unless quiet
152
+ $stdout.puts "failed." unless quiet
82
153
  false
83
154
  end
84
155
  end
85
156
 
157
+ #
158
+ # Abort with an error message to the user that says we cannot load their
159
+ # configuration file.
160
+ #
86
161
  def abort_with_missing_config
87
162
  abort <<-END_MISSING_CONFIG.trim
88
163
  Unable to load configuration file. Please make sure you
@@ -93,6 +168,10 @@ module ScoutAgent
93
168
  END_MISSING_CONFIG
94
169
  end
95
170
 
171
+ #
172
+ # Abort with an error message to the user that says we cannot select a
173
+ # suitable <tt>:user</tt> or <tt>:group</tt> to operate as, based on +type+.
174
+ #
96
175
  def abort_with_not_found(type)
97
176
  choices = Plan.send("#{type}_choices")
98
177
  config = "\n\n config.#{type}_choices = %w[#{choices.join(" ")}]\n\n"
@@ -161,8 +161,8 @@ module ScoutAgent
161
161
  %w[TERM KILL].each { |signal|
162
162
  begin
163
163
  ::Process.kill(signal, child_pid) # attempt to stop process
164
- rescue Errno::ECHILD, Errno::ESRCH # no such process
165
- break # the process is stopped
164
+ rescue Exception # no such process
165
+ break # the process is stopped
166
166
  end
167
167
  if signal == "TERM"
168
168
  # give them a chance to respond
@@ -170,7 +170,8 @@ module ScoutAgent
170
170
  Timeout.timeout(pause) {
171
171
  begin
172
172
  return ::Process.wait2(child_pid).last # response to signal
173
- rescue Errno::ECHILD # no such child
173
+ rescue Exception => error # no such child
174
+ raise if error.is_a? Timeout::Error # reraise timeouts
174
175
  return nil # we have already caught the child
175
176
  end
176
177
  }
@@ -181,8 +182,8 @@ module ScoutAgent
181
182
  }
182
183
  begin
183
184
  ::Process.wait2(child_pid).last
184
- rescue Errno::ECHILD # no such child
185
- nil # we have already caught the child
185
+ rescue Exception # no such child
186
+ nil # we have already caught the child
186
187
  end
187
188
  end
188
189
  end
@@ -271,6 +272,15 @@ module ScoutAgent
271
272
  ::Time.local(*str.to_s.scan(/\d+/))
272
273
  end
273
274
  end
275
+
276
+ #
277
+ # Half of the support for the round-tripping of Time objects to and from
278
+ # JSON. This method reconstructs a Time object from a raw JSON object
279
+ # representation built by our to_json() override.
280
+ #
281
+ def json_create(object)
282
+ ::Time.local(*object["data"])
283
+ end
274
284
  end
275
285
 
276
286
  # Extensions for the Time class.
@@ -283,6 +293,15 @@ module ScoutAgent
283
293
  def to_db_s(trim_seconds = false)
284
294
  strftime("%Y-%m-%d %H:%M#{':%S' unless trim_seconds}")
285
295
  end
296
+
297
+ #
298
+ # Half of the support for the round-tripping of Time objects to and from
299
+ # JSON. This method builds a JSON object representation for Time objects
300
+ # compatible with the JSON library's Ruby conversion support.
301
+ #
302
+ def to_json(*args)
303
+ {:json_class => self.class.name, :data => to_a}.to_json(*args)
304
+ end
286
305
  end
287
306
  end
288
307
  end