scout_agent 3.0.6 → 3.0.7

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