scout_agent 3.0.3 → 3.0.4

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.
data/CHANGELOG CHANGED
@@ -1,3 +1,13 @@
1
+ == 3.0.4
2
+
3
+ * Upgraded to Arrayfields 4.7.3 to silence a warning on Ruby 1.9
4
+ * Added HTTP proxy support
5
+ * Added replies to acknowledge received messages from XMPP when a message ID is
6
+ included in the command
7
+ * Turned XMPP on and aimed it at the real Scout server
8
+ * Greatly improved error handling for the XMPP process
9
+ * Added logging and status tracking for the XMPP operations
10
+
1
11
  == 3.0.3
2
12
 
3
13
  * Upgraded to JSON 1.1.4 for Ruby 1.9 compatibility
data/Rakefile CHANGED
@@ -34,7 +34,7 @@ SA_SPEC = Gem::Specification.new do |spec|
34
34
 
35
35
  spec.require_path = "lib"
36
36
 
37
- spec.add_dependency("arrayfields", "=4.7.2") # fix Amalgalite's results
37
+ spec.add_dependency("arrayfields", "=4.7.3") # fix Amalgalite's results
38
38
  spec.add_dependency("amalgalite", "=0.9.0")
39
39
  spec.add_dependency("rest-client", "=0.9.2")
40
40
  spec.add_dependency("json", "=1.1.4")
data/lib/scout_agent.rb CHANGED
@@ -65,7 +65,7 @@ module ScoutAgent
65
65
  end
66
66
 
67
67
  # The version of this agent.
68
- VERSION = "3.0.3".freeze
68
+ VERSION = "3.0.4".freeze
69
69
  # A Pathname reference to the agent code directory, used in dynamic loading.
70
70
  LIB_DIR = Pathname.new(File.dirname(__FILE__)) + agent_name
71
71
  end
@@ -8,6 +8,8 @@ require "scout_agent/order"
8
8
  module ScoutAgent
9
9
  class Agent
10
10
  class CommunicationAgent < Agent
11
+ RECONNECT_WAIT = 60
12
+
11
13
  def initialize
12
14
  super # setup our log and status
13
15
 
@@ -21,16 +23,13 @@ module ScoutAgent
21
23
  end
22
24
 
23
25
  def run
24
- if Plan.test_mode?
25
- login
26
- update_status("Online since #{Time.now.utc.to_db_s}")
27
- fetch_roster
28
- install_subscriptions_callback
29
- install_messages_callback
30
- listen
31
- else
32
- loop { sleep 60 }
33
- end
26
+ login
27
+ update_status("Online since #{Time.now.utc.to_db_s}")
28
+ fetch_roster
29
+ install_subscriptions_callback
30
+ install_messages_callback
31
+ listen
32
+ close_connection
34
33
  end
35
34
 
36
35
  def finish
@@ -46,40 +45,117 @@ module ScoutAgent
46
45
  def login
47
46
  Thread.abort_on_exception = true # make XMPP4R fail fast
48
47
  @agent_jid = Jabber::JID.new("#{agent_key}@#{jabber_server}/agent")
48
+ log.info("Connecting to XMPP: #{@agent_jid}")
49
49
  @jabber = Jabber::Client.new(@agent_jid)
50
- no_warnings { @jabber.connect }
51
- @jabber.auth(agent_key)
50
+ @jabber.on_exception do |error, stream, during|
51
+ try_connection
52
+ end
53
+ try_connection
54
+ end
55
+
56
+ def try_connection
57
+ until connect_and_authenticate?
58
+ log.info( "Waiting #{RECONNECT_WAIT} seconds before making another " +
59
+ "connection attempt." )
60
+ sleep RECONNECT_WAIT
61
+ end
62
+ end
63
+
64
+ def connect_and_authenticate?
65
+ status("Connecting")
66
+ close_connection
67
+ begin
68
+ no_warnings { @jabber.connect }
69
+ rescue Exception => error # connection failure
70
+ log.error("Failed to connect (#{error.class}: #{error.message}).")
71
+ return false
72
+ end
73
+ begin
74
+ @jabber.auth(agent_key)
75
+ rescue Jabber::ClientAuthenticationFailure # authentication failure
76
+ log.error("Authentication rejected.")
77
+ close_connection
78
+ return false
79
+ end
80
+ true
52
81
  end
53
82
 
54
83
  def update_status(message, status = nil)
84
+ status("Queuing status change")
55
85
  presence = Jabber::Presence.new
56
86
  presence.status = message
57
87
  presence.show = status
58
- @jabber.send(presence)
88
+ # xmpp4r requires sending in a different Thread from the callbacks
89
+ Thread.new do
90
+ log.info("Setting status: #{message}")
91
+ begin
92
+ @jabber.send(presence)
93
+ rescue Exception # failure to update status
94
+ # do nothing: server will still see us online
95
+ log.error("Unable to update status.")
96
+ end
97
+ end
59
98
  end
60
99
 
61
100
  def fetch_roster
101
+ status("Preparing connection")
62
102
  @roster = Jabber::Roster::Helper.new(@jabber)
63
103
  end
64
104
 
65
105
  def install_subscriptions_callback
66
106
  @roster.add_subscription_request_callback do |_, presence|
107
+ log.info("Accepting subscription: #{presence.from}")
67
108
  @roster.accept_subscription(presence.from)
68
109
  end
69
110
  end
70
111
 
71
112
  def install_messages_callback
72
113
  @jabber.add_message_callback do |message|
73
- if order = Order.can_handle?(message)
114
+ log.info("Received message from #{message.from}: #{message.body}")
115
+ status("Parsing command")
116
+ modified_body = nil
117
+ if message.body =~ /\A\s*\[\s*(\d+)\s*\]\s*(.*)/
118
+ modified_body = $2
119
+ send_chat_message(message.from, "received #{$1}")
120
+ end
121
+ if order = Order.can_handle?(message, modified_body)
122
+ status("Processing command")
74
123
  order.execute
124
+ else
125
+ log.warn("Unrecognized message format, ingoring.")
126
+ end
127
+ status("Listening for commands")
128
+ end
129
+ end
130
+
131
+ def send_chat_message(who, body)
132
+ status("Queuing message")
133
+ message = Jabber::Message.new(who, body)
134
+ message.type = :chat
135
+ # xmpp4r requires sending in a different Thread from the callbacks
136
+ Thread.new do
137
+ log.info("Sending message to #{who}: #{body}")
138
+ begin
139
+ @jabber.send(message)
140
+ rescue Exception # failure to send message
141
+ # do nothing: unable to reach the server
142
+ log.error("Unable to send message.")
75
143
  end
76
144
  end
77
145
  end
78
146
 
79
147
  def listen
148
+ log.info("Listening for commands.")
149
+ status("Listening for commands")
80
150
  @shutdown_thread = Thread.current
81
151
  Thread.stop
82
- @jabber.close
152
+ end
153
+
154
+ def close_connection
155
+ @jabber.close! if @jabber.is_connected?
156
+ rescue Exception # connection already closed
157
+ # do nothing: our connection is gone
158
+ log.warn("Failed to close connection.")
83
159
  end
84
160
 
85
161
  def agent_key
@@ -89,7 +165,8 @@ module ScoutAgent
89
165
  end
90
166
 
91
167
  def jabber_server
92
- @jabber_server ||= Plan.test_mode? ? "jabber.org" : "FIXME"
168
+ @jabber_server ||= Plan.test_mode? ? "jabber.org" :
169
+ URI.parse(Plan.server_url).host
93
170
  end
94
171
  end
95
172
  end
@@ -3,6 +3,12 @@
3
3
 
4
4
  module ScoutAgent
5
5
  class Database
6
+ #
7
+ # This database encapsulates the main function of the Scout agent: running
8
+ # missions. Details of the current plan and the missions of that plan are
9
+ # stored in these tables. As missions are executed, they build up reports
10
+ # which are also held here until they can be pushed to the Scout server.
11
+ #
6
12
  class MissionLog < Database
7
13
  #
8
14
  # A default number of seconds a mission is allowed to run before it is
@@ -14,6 +20,10 @@ module ScoutAgent
14
20
  # A size limit for the reports table to prevent data from building up.
15
21
  REPORTS_LIMIT = 3000
16
22
 
23
+ #
24
+ # Build a schema for storing plans, missions, and reports. The reports
25
+ # table is size controlled by trigger to prevent infinite data growth.
26
+ #
17
27
  def update_schema(version = schema_version)
18
28
  case version
19
29
  when 0
@@ -50,6 +60,14 @@ module ScoutAgent
50
60
  end
51
61
  end
52
62
 
63
+ ################
64
+ ### The Plan ###
65
+ ################
66
+
67
+ #
68
+ # Returns the last known plan (+id+ and +last_modified+ date) or +nil+
69
+ # if none exists.
70
+ #
53
71
  def current_plan
54
72
  plan = read_from_sqlite { |sqlite|
55
73
  sqlite.first_row_from(<<-END_FIND_PLAN.trim)
@@ -65,6 +83,13 @@ module ScoutAgent
65
83
  nil # not found
66
84
  end
67
85
 
86
+ #
87
+ # Given a new +last_modified+ date (as a String) and an Array of
88
+ # +missions+, this method attemps and all-or-nothing update of the current
89
+ # plan and missions. The plan and any missions that were already present
90
+ # are simply updated. New missions are added and missions no longer in
91
+ # the list are removed. New missions receive a +next_run_at+ Time of now.
92
+ #
68
93
  def update_plan(last_modified, missions)
69
94
  write_to_sqlite do |sqlite|
70
95
  begin
@@ -127,6 +152,16 @@ module ScoutAgent
127
152
  log.error("Database mission update locking error: #{error.message}.")
128
153
  end
129
154
 
155
+ ################
156
+ ### Missions ###
157
+ ################
158
+
159
+ #
160
+ # Returns the current mission (+id+, +timeout+, +interval+, +last_run_at+,
161
+ # +name+, +code+, +options+, and +memory+) that should be run. The
162
+ # +options+ and +memory+ fields are JSON parsed when possible. If there
163
+ # are no missions scheduled to run at this time, +nil+ is returned.
164
+ #
130
165
  def current_mission
131
166
  mission = read_from_sqlite { |sqlite|
132
167
  return nil unless plan = current_plan
@@ -159,6 +194,10 @@ module ScoutAgent
159
194
  nil # not found
160
195
  end
161
196
 
197
+ #
198
+ # Given a +mission_id+ and a +memory+ Hash, this method updates a
199
+ # mission's stored memory.
200
+ #
162
201
  def update_mission_memory(mission_id, memory)
163
202
  write_to_sqlite do |sqlite|
164
203
  sqlite.execute(<<-END_UPDATE_MISSION.trim, memory.to_json, mission_id)
@@ -170,6 +209,10 @@ module ScoutAgent
170
209
  log.error("Database memory update error: #{error.message}.")
171
210
  end
172
211
 
212
+ #
213
+ # Marks +mission+ as complete in the database by recording its
214
+ # +last_run_at+ Time and setting a +next_run_at+ Time.
215
+ #
173
216
  def complete_mission(mission)
174
217
  write_to_sqlite do |sqlite|
175
218
  run_time = Time.now
@@ -187,7 +230,12 @@ module ScoutAgent
187
230
  false # warn the caller that the mission will still match
188
231
  end
189
232
 
233
+ #
234
+ # All passed mission +ids+ are reset so they will be run again at the
235
+ # first available opportunity.
236
+ #
190
237
  def reset_missions(*ids)
238
+ return if ids.empty?
191
239
  write_to_sqlite do |sqlite|
192
240
  sqlite.execute(<<-END_RESET_MISSIONS.trim, *ids)
193
241
  UPDATE missions
@@ -200,6 +248,38 @@ module ScoutAgent
200
248
  log.error("Database mission reset error: #{error.message}.")
201
249
  end
202
250
 
251
+ #
252
+ # Returns the number of seconds until another mission will be ready for
253
+ # running. If the count would be zero or less seconds, the
254
+ # +DEFAULT_INTERVAL+ is returned (in seconds) to prevent the agent from
255
+ # entering a busy loop.
256
+ #
257
+ def seconds_to_next_mission
258
+ default = DEFAULT_INTERVAL * 60
259
+ next_run_at = read_from_sqlite { |sqlite|
260
+ sqlite.first_value_from(<<-END_FIND_MISSION.trim)
261
+ SELECT next_run_at FROM missions ORDER BY next_run_at LIMIT 1
262
+ END_FIND_MISSION
263
+ }
264
+ if next_run = Time.from_db_s(next_run_at)
265
+ seconds = next_run - Time.now
266
+ seconds > 0 ? seconds : default
267
+ else
268
+ default
269
+ end
270
+ rescue Amalgalite::SQLite3::Error => error # failed to locate last run
271
+ log.error("Database next mission error: #{error.message}.")
272
+ default # use default
273
+ end
274
+
275
+ ###############
276
+ ### Reports ###
277
+ ###############
278
+
279
+ #
280
+ # Adds a report for +mission_id+ of +type+ with +fields+ to the database.
281
+ # Returns +true+ if the write succeeded, or +false+ if it did not.
282
+ #
203
283
  def write_report(mission_id, type, fields)
204
284
  write_to_sqlite do |sqlite|
205
285
  params = [mission_id, type, fields.to_json]
@@ -213,8 +293,24 @@ module ScoutAgent
213
293
  false # couldn't be written
214
294
  end
215
295
 
296
+ #
297
+ # This method returns an Array of all reports (+type+, +fields+,
298
+ # +created_at+, and +plugin_id+) that should be submitted to the Scout
299
+ # server. The report +fields+ will be JSON parsed when possible and
300
+ # +created_at+ is converted to a proper Time object.
301
+ #
302
+ # The act of reading these reports also triggers their removal from the
303
+ # database so we avoid sending duplicates to the server. This does mean
304
+ # that we lose data if anything goes wrong in the sending process. This
305
+ # is considered an acceptable risk, because even a
306
+ # delete-after-a-successful-send stragety is subject to duplication
307
+ # (the request might timeout but eventually complete on the server,
308
+ # for example). If anything goes wrong with the reading or deletion,
309
+ # the entire process is canceled and an empty Array is returned.
310
+ #
216
311
  def current_reports
217
312
  write_to_sqlite { |sqlite|
313
+ # read the current reports
218
314
  begin
219
315
  report_ids = Array.new
220
316
  reports = query(<<-END_FIND_REPORTS.trim) { |row|
@@ -243,6 +339,7 @@ module ScoutAgent
243
339
  return Array.new # return empty results
244
340
  end
245
341
  return reports if reports.empty?
342
+ # delete the reports we read
246
343
  begin
247
344
  sqlite.execute(<<-END_DELETE_REPORTS.trim, *report_ids)
248
345
  DELETE FROM reports
@@ -260,24 +357,6 @@ module ScoutAgent
260
357
  # try again to read reports later
261
358
  log.error("Database reports locking error: #{error.message}.")
262
359
  end
263
-
264
- def seconds_to_next_mission
265
- default = DEFAULT_INTERVAL * 60
266
- next_run_at = read_from_sqlite { |sqlite|
267
- sqlite.first_value_from(<<-END_FIND_MISSION.trim)
268
- SELECT next_run_at FROM missions ORDER BY next_run_at LIMIT 1
269
- END_FIND_MISSION
270
- }
271
- if next_run = Time.from_db_s(next_run_at)
272
- seconds = next_run - Time.now
273
- seconds > 0 ? seconds : default
274
- else
275
- default
276
- end
277
- rescue Amalgalite::SQLite3::Error => error # failed to locate last run
278
- log.error("Database next mission error: #{error.message}.")
279
- default # use default
280
- end
281
360
  end
282
361
  end
283
362
  end
@@ -3,10 +3,19 @@
3
3
 
4
4
  module ScoutAgent
5
5
  class Database
6
+ #
7
+ # This database is used to stored messages queued from external processes.
8
+ # Such messages can be data for missions or full reports ready for
9
+ # submission to the Scout server.
10
+ #
6
11
  class Queue < Database
7
12
  # A size limit for the queue to prevent data from building up.
8
13
  QUEUE_LIMIT = 3000
9
14
 
15
+ #
16
+ # Builds a schema for the queue table. This table is size controlled by a
17
+ # trigger to prevent infinite data growth.
18
+ #
10
19
  def update_schema(version = schema_version)
11
20
  case version
12
21
  when 0
@@ -24,6 +33,14 @@ module ScoutAgent
24
33
  end
25
34
  end
26
35
 
36
+ #
37
+ # Adds a message to the queue. The passed +mission_id+ needs to be an
38
+ # Integer ID for a mission or one of the Strings <tt>'report'</tt>,
39
+ # <tt>'hint'</tt>, <tt>'alert'</tt>, or <tt>'error'</tt> for a full
40
+ # report. The +fields+ parameter is expected to be a Hash of fields and
41
+ # should include a <tt>'plugin_id'</tt> key identifying what the data is
42
+ # for when the message is a report for the server.
43
+ #
27
44
  def enqueue(mission_id, fields)
28
45
  write_to_sqlite do |sqlite|
29
46
  sqlite.execute(<<-END_ENQUEUE.trim, mission_id, fields.to_json)
@@ -36,6 +53,12 @@ module ScoutAgent
36
53
  false # reject bad message
37
54
  end
38
55
 
56
+ #
57
+ # Returns a message (+id+, +fields+, and +created_at+) queued for
58
+ # +mission_id+. The +fields+ are JSON parsed if possible and +created_at+
59
+ # is converted to a Time object. This method will return +nil+ if no
60
+ # messages are queued for +mission_id+.
61
+ #
39
62
  def peek(mission_id)
40
63
  queued = read_from_sqlite { |sqlite|
41
64
  sqlite.first_row_from(<<-END_FIND_QUEUED.trim, mission_id.to_s)
@@ -63,8 +86,16 @@ module ScoutAgent
63
86
  nil # not found
64
87
  end
65
88
 
89
+ #
90
+ # This method returns queued reports intended for the Scout server.
91
+ #
92
+ # The process is pretty much identical to how mission generated reports
93
+ # are pulled. See ScoutAgent::Database::MissionLog#current_reports() for
94
+ # details.
95
+ #
66
96
  def queued_reports
67
97
  write_to_sqlite { |sqlite|
98
+ # read the current reports
68
99
  begin
69
100
  report_ids = Array.new
70
101
  reports = query(<<-END_FIND_REPORTS.trim) { |row|
@@ -95,6 +126,7 @@ module ScoutAgent
95
126
  return Array.new # return empty results
96
127
  end
97
128
  return reports if reports.empty?
129
+ # delete the reports we read
98
130
  unless dequeue(*report_ids)
99
131
  # cancel sending this batch
100
132
  sqlite.rollback # we can't submit unless we're sure they are gone
@@ -107,7 +139,12 @@ module ScoutAgent
107
139
  log.error("Database queued reports locking error: #{error.message}.")
108
140
  end
109
141
 
142
+ #
143
+ # Removes queued messages from the database by their +ids+. Returns
144
+ # +true+ if the removal succeeded, or +false+ otherwise.
145
+ #
110
146
  def dequeue(*ids)
147
+ return true if ids.empty?
111
148
  write_to_sqlite do |sqlite|
112
149
  sqlite.execute(<<-END_DELETE_QUEUED.trim, *ids)
113
150
  DELETE FROM queue WHERE ROWID IN (#{(['?'] * ids.size).join(', ')})
@@ -3,6 +3,12 @@
3
3
 
4
4
  module ScoutAgent
5
5
  class Database
6
+ #
7
+ # This database holds snapshot commands and results. These commands are
8
+ # used as a way of recording the current state of the box the agent runs on.
9
+ # The results of these commands may help identify causes of problems
10
+ # reported by missions the agent runs.
11
+ #
6
12
  class Snapshots < Database
7
13
  # A maximum time in seconds after which a command run is halted.
8
14
  DEFAULT_TIMEOUT = 60
@@ -14,6 +20,11 @@ module ScoutAgent
14
20
  # A size limit for the runs table to prevent data from building up.
15
21
  RUNS_LIMIT = 3000
16
22
 
23
+ #
24
+ # Builds a schema for tables holding commands and the runs of those
25
+ # commands. The runs table is size controlled via a trigger to prevent
26
+ # infinite data growth.
27
+ #
17
28
  def update_schema(version = schema_version)
18
29
  case version
19
30
  when 0
@@ -39,7 +50,12 @@ module ScoutAgent
39
50
  END_INITIAL_SCHEMA
40
51
  end
41
52
  end
42
-
53
+
54
+ #
55
+ # Updates the list of snapshot +commands+ in the database. Existing
56
+ # commands are updated, new commands are added, and commands no longer in
57
+ # the list are deleted.
58
+ #
43
59
  def update_commands(commands)
44
60
  write_to_sqlite do |sqlite|
45
61
  codes = commands.map { |m| m["code"] }
@@ -83,6 +99,11 @@ module ScoutAgent
83
99
  log.error("Database command update locking error: #{error.message}.")
84
100
  end
85
101
 
102
+ #
103
+ # Returns all current commands (+id+, +timeout+, +interval+,
104
+ # +last_run_at+, and +code+) that should be executed as part of the
105
+ # current snapshot. An empty Array is returned if no commands are found.
106
+ #
86
107
  def current_commands
87
108
  query(<<-END_FIND_COMMANDS.trim, Time.now.to_db_s) { |row|
88
109
  SELECT ROWID AS id, timeout, interval, last_run_at, code
@@ -96,6 +117,11 @@ module ScoutAgent
96
117
  Array.new # return empty results
97
118
  end
98
119
 
120
+ #
121
+ # Returns +true+ or +false+ to indicate if any commands are stored in the
122
+ # snapshot database. If the database is inaccessible and the answer
123
+ # cannot be determined, +nil+ is returned.
124
+ #
99
125
  def have_commands?
100
126
  read_from_sqlite { |sqlite|
101
127
  !!sqlite.first_value_from("SELECT ROWID FROM commands LIMIT 1")
@@ -105,8 +131,14 @@ module ScoutAgent
105
131
  nil # commands not found
106
132
  end
107
133
 
134
+ #
135
+ # Marks +command+ as just having run in the database and updates its
136
+ # +next_run_at+ Time. A run is also created for the +command+ documenting
137
+ # its +output+, +exit_status+, +snapshot_at+ Time, and +run_time+.
138
+ #
108
139
  def complete_run(command, output, exit_status, snapshot_at, run_time)
109
140
  write_to_sqlite do |sqlite|
141
+ # record run
110
142
  params = [ command[:code],
111
143
  output,
112
144
  exit_status,
@@ -123,6 +155,7 @@ module ScoutAgent
123
155
  log.error( "Database bad command run (#{command[:code]}) error: " +
124
156
  "#{error.message}." )
125
157
  end
158
+ # update command
126
159
  run_time = Time.now
127
160
  params = [ run_time.to_db_s,
128
161
  ( run_time +
@@ -143,8 +176,15 @@ module ScoutAgent
143
176
  log.error("Database complete command locking error: #{error.message}.")
144
177
  end
145
178
 
179
+ #
180
+ # This method returns command runs intended for the Scout server.
181
+ #
182
+ # The process is very similar to how mission generated reports are pulled.
183
+ # See ScoutAgent::Database::MissionLog#current_reports() for details.
184
+ #
146
185
  def current_runs
147
186
  write_to_sqlite { |sqlite|
187
+ # read the current runs
148
188
  begin
149
189
  run_ids = Array.new
150
190
  runs = query(<<-END_FIND_RUNS.trim) { |row|
@@ -166,6 +206,7 @@ module ScoutAgent
166
206
  return Array.new # return empty results
167
207
  end
168
208
  return runs if runs.empty?
209
+ # delete the runs we read
169
210
  begin
170
211
  sqlite.execute(<<-END_DELETE_RUNS.trim, *run_ids)
171
212
  DELETE FROM runs
@@ -3,7 +3,13 @@
3
3
 
4
4
  module ScoutAgent
5
5
  class Database
6
+ #
7
+ # This small database keeps track of the current status of all running
8
+ # processes withing the agent. This allows tracking of process function
9
+ # (represented as the process name), ID, and current task.
10
+ #
6
11
  class Statuses < Database
12
+ # Builds a schema for the process statuses table.
7
13
  def update_schema(version = schema_version)
8
14
  case version
9
15
  when 0
@@ -21,6 +27,11 @@ module ScoutAgent
21
27
  end
22
28
  end
23
29
 
30
+ #
31
+ # Record the current +status+ for the calling process. The process ID is
32
+ # determined with <tt>Process::pid()</tt> and +name+ is pulled from
33
+ # <tt>IDCard::me()#process_name()</tt> when available.
34
+ #
24
35
  def update_status(status, name = IDCard.me && IDCard.me.process_name)
25
36
  write_to_sqlite do |sqlite|
26
37
  sqlite.execute(<<-END_UPDATE_STATUS.trim, name, Process.pid, status)
@@ -33,6 +44,11 @@ module ScoutAgent
33
44
  log.error("Database status update error: #{error.message}.")
34
45
  end
35
46
 
47
+ #
48
+ # Removes the status record for +name+ (pulled from
49
+ # <tt>IDCard::me()#process_name()</tt> by default). This is generally
50
+ # done by processes in an <tt>at_exit()</tt> block.
51
+ #
36
52
  def clear_status(name = IDCard.me && IDCard.me.process_name)
37
53
  write_to_sqlite do |sqlite|
38
54
  sqlite.execute("DELETE FROM statuses WHERE name = ?", name)
@@ -42,6 +58,11 @@ module ScoutAgent
42
58
  log.error("Database status clearing error: #{error.message}.")
43
59
  end
44
60
 
61
+ #
62
+ # Returns the current statuses (+name+, +pid+, +status+,
63
+ # and +last_updated_at+) for all known processes. An empty Array is
64
+ # returned if no processes are currently active.
65
+ #
45
66
  def current_statuses
46
67
  query(<<-END_FIND_STATUSES.trim)
47
68
  SELECT name, pid, status, last_updated_at FROM statuses ORDER BY ROWID
@@ -51,6 +72,10 @@ module ScoutAgent
51
72
  Array.new # return empty results
52
73
  end
53
74
 
75
+ #
76
+ # Returns the current +status+ message for +name+ (pulled from
77
+ # <tt>IDCard::me()#process_name()</tt> by default).
78
+ #
54
79
  def current_status(name = IDCard.me && IDCard.me.process_name)
55
80
  read_from_sqlite { |sqlite|
56
81
  sqlite.first_value_from(<<-END_FIND_STATUS, name)
@@ -32,6 +32,10 @@ module ScoutAgent
32
32
  "The URL for the server to report to." ) do |url|
33
33
  switches[:server_url] = url
34
34
  end
35
+ opts.on( "-p", "--proxy URL", String,
36
+ "A proxy URL to pass HTTP requests through." ) do |url|
37
+ switches[:proxy_url] = url
38
+ end
35
39
  opts.on( "-d", "--[no-]daemon",
36
40
  "Run in the background as a daemon." ) do |boolean|
37
41
  switches[:run_as_daemon] = boolean
@@ -41,16 +41,17 @@ module ScoutAgent
41
41
  Plan.pid_dir + "#{@process_name}.pid"
42
42
  end
43
43
 
44
- # Returns the PID for the named process, or +nil+ if it cannot be read.
44
+ # Returns the PID for the named process or +nil+ if it cannot be read.
45
45
  def pid
46
46
  pid_file.read.to_i
47
- rescue Exception
47
+ rescue Exception # cannot read file
48
48
  nil
49
49
  end
50
50
 
51
51
  #
52
52
  # Tries to send +message+ as a signal to the process represented by this
53
- # instance. You can pass any message Process.kill() would understand.
53
+ # instance. You can pass any message <tt>Process.kill()</tt> would
54
+ # understand.
54
55
  #
55
56
  # Returns +true+ if the signal was sent, or +false+ if the PID file could
56
57
  # not be read. Any Exception raised during the send, such as Errno::ESRCH
@@ -71,9 +72,9 @@ module ScoutAgent
71
72
  # stale claims are ignored and replaced, if possible.
72
73
  #
73
74
  # This method returns +true+ in the claim succeeded and +false+ if it could
74
- # not happen for any reason. A return of +true+ indicates that me() has
75
- # been updated and an exit handle has been installed to revoke() this claim
76
- # as the process ends.
75
+ # not happen for any reason. A return of +true+ indicates that
76
+ # <tt>IDCard::me()</tt> has been updated and an exit handle has been
77
+ # installed to revoke() this claim as the process ends.
77
78
  #
78
79
  def authorize
79
80
  File.open(pid_file, File::CREAT | File::EXCL | File::WRONLY) do |pid|
@@ -91,9 +92,7 @@ module ScoutAgent
91
92
  self.class.me = self
92
93
 
93
94
  at_my_exit do
94
- unless revoke
95
- # log.error "Unable to unlink pid file: #{$!.message}" if log
96
- end
95
+ revoke
97
96
  end
98
97
  true
99
98
  rescue Errno::EEXIST # pid_file already exists
@@ -101,32 +100,24 @@ module ScoutAgent
101
100
  if pid.flock(File::LOCK_EX | File::LOCK_NB)
102
101
  if pid.read =~ /\A\d+/
103
102
  begin
104
- unless signal(0)
105
- # log.warn "Could not create or read PID file. " +
106
- # "You may need to the path to the config directory. " +
107
- # "See: http://scoutapp.com/help#data_file" if log
108
- end
103
+ signal(0) # check for the existing process
109
104
  rescue Errno::ESRCH # no such process
110
- # log.info "Stale PID file found. Clearing it and reloading..." if log
105
+ # stale PID file found, clearing it and reloading
111
106
  if revoke
112
107
  pid.flock(File::LOCK_UN) # release the lock before we recurse
113
108
  return authorize # try again
114
- else
115
- # log.info "Failed to clear PID." if log
116
109
  end
117
110
  rescue Errno::EACCES # don't have permission
118
111
  # nothing we can do so give up
119
112
  end
120
- else
121
- # nothing we can do so give up
122
113
  end
123
114
  pid.flock(File::LOCK_UN) # release the lock
124
115
  else
125
- # log.info "Couldn't grab a file lock to verify existing PID file." if log
116
+ # couldn't grab a file lock to verify existing PID file
126
117
  return false
127
118
  end
128
119
  end
129
- # log.warn "Process #{pid} was already running" if log
120
+ # process was already running
130
121
  false
131
122
  end
132
123
 
@@ -28,16 +28,22 @@ module ScoutAgent
28
28
  subclasses
29
29
  end
30
30
 
31
- def self.can_handle?(message)
31
+ def self.can_handle?(message, modified_body = nil)
32
32
  subclasses.each do |order|
33
- if match_details = order.match?(message)
33
+ if match_details = order.match?(message, modified_body)
34
34
  return order.new(message, match_details)
35
35
  end
36
36
  end
37
+ nil
37
38
  end
38
39
 
39
- def self.match?(message)
40
- const_defined?(:MATCH_RE) and const_get(:MATCH_RE).match(message.body)
40
+ def self.match?(message, modified_body = nil)
41
+ const_defined?(:MATCH_RE) and
42
+ const_get(:MATCH_RE).match(modified_body || message.body)
43
+ end
44
+
45
+ def self.master_agent
46
+ @master_agent ||= IDCard.new(:master)
41
47
  end
42
48
 
43
49
  def initialize(message, match_details)
@@ -55,5 +61,12 @@ module ScoutAgent
55
61
  raise NotImplementedError,
56
62
  "Subclasses must override ScoutAgent::Order#execute()."
57
63
  end
64
+
65
+ def notify_master
66
+ self.class.master_agent.signal("ALRM")
67
+ rescue Exception # unable to signal process
68
+ # do nothing: process will catch up when it restarts
69
+ log.warn("Unable to signal master process.")
70
+ end
58
71
  end
59
72
  end
@@ -15,19 +15,23 @@ module ScoutAgent
15
15
  @mission_log = db
16
16
  end
17
17
 
18
- def self.master_agent
19
- @master_agent ||= IDCard.new(:master)
20
- end
21
-
22
18
  def execute
23
19
  if id_str = match_details.captures.first
24
20
  ids = id_str.scan(/\d+/).map { |n| n.to_i }
25
21
  unless ids.empty?
26
- self.class.mission_log.reset_missions(ids)
22
+ s = ids.size == 1 ? "" : "s"
23
+ ids_str = case ids.size
24
+ when 1 then ids.first.to_s
25
+ when 2 then ids.join(" and ")
26
+ else ids[0..-2].join(", ") + ", and #{ids.last}"
27
+ end
28
+ log.info("Clearing wait time#{s} for mission#{s} (#{ids_str}).")
29
+ self.class.mission_log.reset_missions(*ids)
27
30
  end
28
31
  end
29
- self.class.master_agent.signal("ALRM")
32
+ log.info("Requesting an immediate check-in.")
33
+ notify_master
30
34
  end
31
35
  end
32
36
  end
33
- end
37
+ end
@@ -4,30 +4,12 @@
4
4
  module ScoutAgent
5
5
  class Order
6
6
  class SnapshotOrder < Order
7
- MATCH_RE = /\A\s*snap[-_]?shot(?:\s+(.+?))?\s*\z/
8
-
9
- def self.snapshots
10
- return @snapshots if defined? @snapshots
11
- unless db = Database.load(:snapshots, log)
12
- log.fatal("Could not load snapshots database.")
13
- exit
14
- end
15
- @snapshots = db
16
- end
17
-
18
- def self.master_agent
19
- @master_agent ||= IDCard.new(:master)
20
- end
7
+ MATCH_RE = /\A\s*snap[-_]?shot\s*\z/
21
8
 
22
9
  def execute
23
- # if id_str = match_details.captures.first
24
- # ids = id_str.scan(/\d+/).map { |n| n.to_i }
25
- # unless ids.empty?
26
- # self.class.mission_log.reset_missions(ids)
27
- # end
28
- # end
10
+ log.info("Requesting that a snapshot be taken.")
29
11
  API.take_snapshot
30
- self.class.master_agent.signal("ALRM")
12
+ notify_master
31
13
  end
32
14
  end
33
15
  end
@@ -18,6 +18,7 @@ module ScoutAgent
18
18
  # The default configuration for this agent.
19
19
  def defaults
20
20
  [ [:server_url, "http://beta.scoutapp.com:3000"],
21
+ [:proxy_url, nil],
21
22
  [:run_as_daemon, true],
22
23
  [:logging_level, "INFO"],
23
24
  [:test_mode, false],
@@ -37,7 +38,9 @@ module ScoutAgent
37
38
 
38
39
  # This method is used to set or reset the default configuration.
39
40
  def set_defaults
40
- defaults.each { |name, value| send("#{name}=", value) }
41
+ defaults.each do |name, value|
42
+ send("#{name}=", value)
43
+ end
41
44
  end
42
45
  alias_method :reset_defaults, :set_defaults
43
46
 
@@ -74,6 +77,7 @@ module ScoutAgent
74
77
  config_file.open(File::CREAT|File::EXCL|File::WRONLY) do |pid|
75
78
  pid.puts <<-END_DEFAULT_CONFIG.trim
76
79
  #!/usr/bin/env ruby -wKU
80
+ # encoding: UTF-8
77
81
 
78
82
  #
79
83
  # This file configures #{ScoutAgent.proper_agent_name}. The settings in
@@ -92,6 +96,12 @@ module ScoutAgent
92
96
 
93
97
  # The following is the URL used to reach the server:
94
98
  config.server_url = #{server_url.inspect}
99
+ #
100
+ # Set the following if your HTTP requests need to go through a proxy.
101
+ # All non-XMPP requests between the agent and the server will go through
102
+ # this URL.
103
+ #
104
+ config.proxy_url = #{proxy_url.inspect}
95
105
 
96
106
  #
97
107
  # When the following is true, the agent will disconnect from the
@@ -139,8 +149,8 @@ module ScoutAgent
139
149
  # Note: to have the prefix effect the location of this file or
140
150
  # to set your OS's configuration path, you will need to use
141
151
  # command-line switches (--prefix and --os-config-path respectively).
142
- # It wouldn't help to have those settings in this file, for obvious
143
- # reasons.
152
+ # It wouldn't help to have those settings in this file as they are
153
+ # needed before this file is loaded.
144
154
  #
145
155
  config.prefix_path = #{prefix_path.to_s.inspect}
146
156
 
@@ -161,11 +171,11 @@ module ScoutAgent
161
171
  # try to switch to. Choices are tried from left to right, so by
162
172
  # default the "daemon" user is used but the agent will try
163
173
  # "nobody" if daemon is not available. These are standard users
164
- # and groups for processes like this that run on Unix.
174
+ # and groups for long running processes on Unix.
165
175
  #
166
176
  # If you wish to improve security even more, we recommend creating
167
177
  # a user and group called "#{ScoutAgent.agent_name}" and replacing these
168
- # defaults with what you created. This puts you full control
178
+ # defaults with what you created. This puts you in full control
169
179
  # of what the agent will have access to on your system and makes
170
180
  # it easier to track what the agent is doing.
171
181
  #
@@ -193,7 +203,9 @@ module ScoutAgent
193
203
 
194
204
  # This method allows mass update through a +hash+ of settings.
195
205
  def update_from_switches(hash)
196
- hash.each { |name, value| send("#{name}=", value) }
206
+ hash.each do |name, value|
207
+ send("#{name}=", value)
208
+ end
197
209
  end
198
210
  alias_method :update_from_hash, :update_from_switches
199
211
 
@@ -211,9 +223,9 @@ module ScoutAgent
211
223
  # These paths prefix all of the specific application paths.
212
224
  #
213
225
  %w[os_config_path os_db_path os_pid_path os_log_path].each do |path|
214
- define_method(path) do
226
+ define_method(path) {
215
227
  prefix_path + super()
216
- end
228
+ }
217
229
  end
218
230
 
219
231
  #
@@ -230,9 +242,9 @@ module ScoutAgent
230
242
  # are named after the agent.
231
243
  #
232
244
  %w[db pid log].each do |path|
233
- define_method("#{path}_dir") do
245
+ define_method("#{path}_dir") {
234
246
  send("os_#{path}_path") + (super() || ScoutAgent.agent_name)
235
- end
247
+ }
236
248
  end
237
249
 
238
250
  #
@@ -243,7 +255,7 @@ module ScoutAgent
243
255
  { :db_dir => 0777,
244
256
  :pid_dir => 0775,
245
257
  :log_dir => 0777 }.each do |path, permissions|
246
- define_method("build_#{path}") do |group_id|
258
+ define_method("build_#{path}") { |group_id|
247
259
  begin
248
260
  send(path).mkpath
249
261
  send(path).chown(nil, group_id)
@@ -252,13 +264,17 @@ module ScoutAgent
252
264
  rescue Errno::EACCES, Errno::EPERM # don't have permission
253
265
  false
254
266
  end
255
- end
267
+ }
256
268
  end
257
269
 
258
270
  #############
259
271
  ### URL's ###
260
272
  #############
261
273
 
274
+ #
275
+ # Returns the full URL for check-ins by this agent. This is the
276
+ # +server_url+ plus the agent path and the key specific to this agent.
277
+ #
262
278
  def agent_url
263
279
  URI.join(server_url, "clients/#{agent_key}")
264
280
  end
@@ -267,6 +283,12 @@ module ScoutAgent
267
283
  ### Databases ###
268
284
  #################
269
285
 
286
+ #
287
+ # This method is used to prepare a database that can be accessed by any
288
+ # process. The database is first loaded by +name+. Then the permissions
289
+ # on that database are changed to allow all processes to read and write to
290
+ # the created database.
291
+ #
270
292
  def prepare_global_database(name)
271
293
  db = Database.load(name) or return false
272
294
  db.path.chmod(0777)
@@ -18,6 +18,8 @@ module ScoutAgent
18
18
  :headers => { :client_version => ScoutAgent::VERSION,
19
19
  :accept_encoding => "gzip" }
20
20
  )
21
+ # make sure proxy is set, if needed
22
+ RestClient.proxy = Plan.proxy_url
21
23
  end
22
24
 
23
25
  # The log connection notes will be written to.
@@ -40,13 +42,8 @@ module ScoutAgent
40
42
  log.warn("Plan was malformed zipped data.")
41
43
  "" # replace bad plan with empty plan
42
44
  rescue RestClient::RequestFailed => error # RestClient bug workaround
43
- # all success codes are OK, but other codes are not
44
- if error.http_code.between? 200, 299
45
- error.response.body # the returned plan
46
- else
47
- log.warn("Plan was returned as a failure code: #{error.http_code}.")
48
- nil # failed to retrieve plan
49
- end
45
+ log.warn("Plan was returned as a failure code: #{error.http_code}.")
46
+ nil # failed to retrieve plan
50
47
  rescue RestClient::NotModified
51
48
  log.info("Plan was not modified.")
52
49
  "" # empty plan
@@ -61,7 +58,7 @@ module ScoutAgent
61
58
  # as much as possible.
62
59
  #
63
60
  # If the ckeck-in succeeds, +true+ is returned. However, +false+ doesn't
64
- # ensure that we failed. RestClient may time out a long connection attempt,
61
+ # ensure that we failed. RestClient may timeout a long connection attempt,
65
62
  # but the server may still complete it eventually.
66
63
  #
67
64
  def post_checkin(data)
@@ -38,7 +38,7 @@ module ScoutAgent
38
38
  # status(status, name = IDCard.me && IDCard.me.process_name)
39
39
  #
40
40
  # Sets the +status+ of this process. You can pass +name+ explicitly if it
41
- # cannot be properly determined from <tt>IDCard.me()</tt>.
41
+ # cannot be properly determined from <tt>IDCard::me()#process_name()</tt>.
42
42
  #
43
43
  def status(*args)
44
44
  status_database.update_status(*args) if status_database
@@ -51,7 +51,7 @@ module ScoutAgent
51
51
  # Clears any status currently set for this process. This should be called
52
52
  # for any process setting status messages <tt>at_exit()</tt>. You can pass
53
53
  # +name+ explicitly if it cannot be properly determined from
54
- # <tt>IDCard.me()</tt>.
54
+ # <tt>IDCard::me()#process_name()</tt>.
55
55
  #
56
56
  def clear_status(*args)
57
57
  status_database.clear_status(*args) if status_database
data/test/tc_plan.rb CHANGED
@@ -99,7 +99,7 @@ class TestPlan < Test::Unit::TestCase
99
99
  end
100
100
 
101
101
  def test_server_url_is_set
102
- assert_match(%r{\Ahttps://}, plan.server_url)
102
+ assert_match(%r{\Ahttps?://}, plan.server_url)
103
103
  end
104
104
 
105
105
  def test_run_as_daemon_is_set
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: scout_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.0.3
4
+ version: 3.0.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - James Edward Gray II
@@ -12,7 +12,7 @@ autorequire:
12
12
  bindir: bin
13
13
  cert_chain: []
14
14
 
15
- date: 2009-04-06 00:00:00 -05:00
15
+ date: 2009-04-13 00:00:00 -05:00
16
16
  default_executable:
17
17
  dependencies:
18
18
  - !ruby/object:Gem::Dependency
@@ -23,7 +23,7 @@ dependencies:
23
23
  requirements:
24
24
  - - "="
25
25
  - !ruby/object:Gem::Version
26
- version: 4.7.2
26
+ version: 4.7.3
27
27
  version:
28
28
  - !ruby/object:Gem::Dependency
29
29
  name: amalgalite