scout 5.3.5 → 5.4.4.alpha

Sign up to get free protection for your applications and to get access to all the features.
Files changed (66) hide show
  1. data/.gitignore +6 -0
  2. data/CHANGELOG +0 -12
  3. data/Gemfile +4 -0
  4. data/README +8 -0
  5. data/Rakefile +6 -108
  6. data/bin/scout +1 -0
  7. data/lib/scout.rb +5 -4
  8. data/lib/scout/command.rb +11 -12
  9. data/lib/scout/command/install.rb +1 -1
  10. data/lib/scout/command/run.rb +13 -1
  11. data/lib/scout/command/sign.rb +2 -8
  12. data/lib/scout/command/stream.rb +50 -0
  13. data/lib/scout/command/test.rb +1 -1
  14. data/lib/scout/daemon_spawn.rb +215 -0
  15. data/lib/scout/plugin.rb +20 -1
  16. data/lib/scout/server.rb +16 -111
  17. data/lib/scout/server_base.rb +100 -0
  18. data/lib/scout/streamer.rb +162 -0
  19. data/lib/scout/streamer_control.rb +43 -0
  20. data/lib/scout/version.rb +3 -0
  21. data/scout.gemspec +27 -0
  22. data/test/plugins/disk_usage.rb +86 -0
  23. data/test/scout_test.rb +598 -0
  24. data/vendor/pusher-gem/Gemfile +2 -0
  25. data/vendor/pusher-gem/LICENSE +20 -0
  26. data/vendor/pusher-gem/README.md +80 -0
  27. data/vendor/pusher-gem/Rakefile +11 -0
  28. data/vendor/pusher-gem/examples/async_message.rb +28 -0
  29. data/vendor/pusher-gem/lib/pusher.rb +107 -0
  30. data/vendor/pusher-gem/lib/pusher/channel.rb +154 -0
  31. data/vendor/pusher-gem/lib/pusher/request.rb +107 -0
  32. data/vendor/pusher-gem/pusher.gemspec +28 -0
  33. data/vendor/pusher-gem/spec/channel_spec.rb +274 -0
  34. data/vendor/pusher-gem/spec/pusher_spec.rb +87 -0
  35. data/vendor/pusher-gem/spec/spec_helper.rb +13 -0
  36. data/vendor/ruby-hmac/History.txt +15 -0
  37. data/vendor/ruby-hmac/Manifest.txt +11 -0
  38. data/vendor/ruby-hmac/README.md +41 -0
  39. data/vendor/ruby-hmac/Rakefile +23 -0
  40. data/vendor/ruby-hmac/lib/hmac-md5.rb +11 -0
  41. data/vendor/ruby-hmac/lib/hmac-rmd160.rb +11 -0
  42. data/vendor/ruby-hmac/lib/hmac-sha1.rb +11 -0
  43. data/vendor/ruby-hmac/lib/hmac-sha2.rb +25 -0
  44. data/vendor/ruby-hmac/lib/hmac.rb +118 -0
  45. data/vendor/ruby-hmac/lib/ruby_hmac.rb +2 -0
  46. data/vendor/ruby-hmac/ruby-hmac.gemspec +33 -0
  47. data/vendor/ruby-hmac/test/test_hmac.rb +89 -0
  48. data/vendor/signature/.document +5 -0
  49. data/vendor/signature/.gitignore +21 -0
  50. data/vendor/signature/Gemfile +3 -0
  51. data/vendor/signature/Gemfile.lock +29 -0
  52. data/vendor/signature/LICENSE +20 -0
  53. data/vendor/signature/README.md +55 -0
  54. data/vendor/signature/Rakefile +2 -0
  55. data/vendor/signature/VERSION +1 -0
  56. data/vendor/signature/lib/signature.rb +142 -0
  57. data/vendor/signature/lib/signature/version.rb +3 -0
  58. data/vendor/signature/signature.gemspec +22 -0
  59. data/vendor/signature/spec/signature_spec.rb +176 -0
  60. data/vendor/signature/spec/spec_helper.rb +10 -0
  61. data/vendor/util/lib/core_extensions.rb +60 -0
  62. metadata +120 -84
  63. data/AUTHORS +0 -4
  64. data/COPYING +0 -340
  65. data/INSTALL +0 -18
  66. data/TODO +0 -6
data/lib/scout/plugin.rb CHANGED
@@ -49,6 +49,16 @@ module Scout
49
49
  code =~ EMBEDDED_OPTIONS_REGEX
50
50
  return $2
51
51
  end
52
+
53
+ def extract_code_class(code)
54
+ match = /class\s\b(\w*)\s+?<\s+Scout::Plugin/.match(code)
55
+
56
+ if match
57
+ return match[1]
58
+ else
59
+ raise ArgumentError, "can't identify plugin class"
60
+ end
61
+ end
52
62
  end
53
63
 
54
64
  # Creates a new Scout Plugin to run.
@@ -122,7 +132,16 @@ module Scout
122
132
  alias_method :add_#{kind}, :#{kind}
123
133
  END
124
134
  end
125
-
135
+
136
+ # resets everything except memory. Memory stays intact. This is used for real-time reporting
137
+ def reset!
138
+ @data_for_server = { :reports => [ ],
139
+ :alerts => [ ],
140
+ :errors => [ ],
141
+ :summaries => [ ],
142
+ :memory => @memory }
143
+ end
144
+
126
145
  #
127
146
  # Usage:
128
147
  #
data/lib/scout/server.rb CHANGED
@@ -1,29 +1,13 @@
1
- #!/usr/bin/env ruby -wKU
2
-
3
- require "net/https"
4
- require "uri"
5
- require "yaml"
6
- require "timeout"
7
- require "stringio"
8
- require "zlib"
9
- require "socket"
10
- require "base64"
11
-
12
- $LOAD_PATH << File.join(File.dirname(__FILE__), *%w[.. .. vendor json_pure lib])
1
+
2
+ Dir.glob(File.join(File.dirname(__FILE__), *%w[.. .. vendor *])).each do |dir|
3
+ $LOAD_PATH << File.join(dir,"lib")
4
+ end
5
+
13
6
  require "json"
7
+ require "pusher"
14
8
 
15
9
  module Scout
16
- class Server
17
- # A new class for plugin Timeout errors.
18
- class PluginTimeoutError < RuntimeError; end
19
- # A new class for API Timeout errors.
20
- class APITimeoutError < RuntimeError; end
21
-
22
- # Headers passed up with all API requests.
23
- HTTP_HEADERS = { "Client-Version" => Scout::VERSION,
24
- "Client-Hostname" => Socket.gethostname,
25
- "Accept-Encoding" => "gzip" }
26
-
10
+ class Server < Scout::ServerBase
27
11
  #
28
12
  # A plugin cannot take more than DEFAULT_PLUGIN_TIMEOUT seconds to execute,
29
13
  # otherwise, a timeout error is generated. This can be overriden by
@@ -39,20 +23,20 @@ module Scout
39
23
  attr_reader :new_plan
40
24
  attr_reader :directives
41
25
  attr_reader :plugin_config
26
+ attr_reader :streamer_command
42
27
 
43
28
  # Creates a new Scout Server connection.
44
- def initialize(server, client_key, history_file, logger = nil, server_name=nil, http_proxy='', https_proxy='')
29
+ def initialize(server, client_key, history_file, logger = nil, server_name=nil)
45
30
  @server = server
46
31
  @client_key = client_key
47
32
  @history_file = history_file
48
33
  @history = Hash.new
49
34
  @logger = logger
50
35
  @server_name = server_name
51
- @http_proxy = http_proxy
52
- @https_proxy = https_proxy
53
36
  @plugin_plan = []
54
37
  @plugins_with_signature_errors = []
55
38
  @directives = {} # take_snapshots, interval, sleep_interval
39
+ @streamer_command = nil
56
40
  @new_plan = false
57
41
  @local_plugin_path = File.dirname(history_file) # just put overrides and ad-hoc plugins in same directory as history file.
58
42
  @plugin_config_path = File.join(@local_plugin_path, "plugins.properties")
@@ -68,6 +52,7 @@ module Scout
68
52
  end
69
53
 
70
54
  def refresh?
55
+ #info "called refresh: ping_key=#{ping_key}"
71
56
  return true if !ping_key or account_public_key_changed? # fetch the plan again if the account key is modified/created
72
57
 
73
58
  url=URI.join( @server.sub("https://","http://"), "/clients/#{ping_key}/ping.scout")
@@ -77,6 +62,8 @@ module Scout
77
62
  headers["If-Modified-Since"] = @history["plan_last_modified"]
78
63
  end
79
64
  get(url, "Could not ping #{url} for refresh info", headers) do |res|
65
+ info "inside 'refresh?' #{res.to_hash.to_json}"
66
+ @streamer_command = res["x-streamer-command"] # usually will be nil, but can be [start,abcd,1234,5678|stop]
80
67
  if res.is_a?(Net::HTTPNotModified)
81
68
  return false
82
69
  else
@@ -107,7 +94,6 @@ module Scout
107
94
  if res["Content-Encoding"] == "gzip" and body and not body.empty?
108
95
  body = Zlib::GzipReader.new(StringIO.new(body)).read
109
96
  end
110
-
111
97
  body_as_hash = JSON.parse(body)
112
98
 
113
99
  temp_plugins=Array(body_as_hash["plugins"])
@@ -152,7 +138,6 @@ module Scout
152
138
 
153
139
  @new_plan = true # used in determination if we should checkin this time or not
154
140
 
155
-
156
141
  # Add local plugins to the plan.
157
142
  @plugin_plan += get_local_plugins
158
143
  rescue Exception =>e
@@ -165,6 +150,7 @@ module Scout
165
150
  @plugin_plan = Array(@history["old_plugins"])
166
151
  @plugin_plan += get_local_plugins
167
152
  @directives = @history["directives"] || Hash.new
153
+
168
154
  end
169
155
  @plugin_plan.reject! { |p| p['code'].nil? }
170
156
  end
@@ -245,7 +231,7 @@ module Scout
245
231
  def time_to_checkin?
246
232
  @history['last_checkin'] == nil ||
247
233
  @directives['interval'] == nil ||
248
- (Time.now.to_i - Time.at(@history['last_checkin']).to_i).abs+15+sleep_interval > @directives['interval'].to_i*60
234
+ (Time.now.to_i - Time.at(@history['last_checkin']).to_i).abs+15 > @directives['interval'].to_i*60
249
235
  rescue
250
236
  debug "Failed to calculate time_to_checkin. @history['last_checkin']=#{@history['last_checkin']}. "+
251
237
  "@directives['interval']=#{@directives['interval']}. Time.now.to_i=#{Time.now.to_i}"
@@ -495,7 +481,7 @@ module Scout
495
481
  contents=File.read(@history_file)
496
482
  begin
497
483
  @history = YAML.load(contents)
498
- rescue
484
+ rescue => e
499
485
  backup_path=File.join(File.dirname(@history_file), "history.corrupt")
500
486
  info "Couldn't parse the history file. Deleting it and resetting to an empty history file. Keeping a backup at #{backup_path}"
501
487
  File.open(backup_path,"w"){|f|f.write contents}
@@ -540,70 +526,6 @@ module Scout
540
526
  }
541
527
  end
542
528
 
543
- def urlify(url_name, options = Hash.new)
544
- return unless @server
545
- options.merge!(:client_version => Scout::VERSION)
546
- URI.join( @server,
547
- "/clients/CLIENT_KEY/#{url_name}.scout".
548
- gsub(/\bCLIENT_KEY\b/, @client_key).
549
- gsub(/\b[A-Z_]+\b/) { |k| options[k.downcase.to_sym] || k } )
550
- end
551
-
552
- def post(url, error, body, headers = Hash.new, &response_handler)
553
- return unless url
554
- request(url, response_handler, error) do |connection|
555
- post = Net::HTTP::Post.new( url.path +
556
- (url.query ? ('?' + url.query) : ''),
557
- HTTP_HEADERS.merge(headers) )
558
- post.body = body
559
- connection.request(post)
560
- end
561
- end
562
-
563
- def get(url, error, headers = Hash.new, &response_handler)
564
- return unless url
565
- request(url, response_handler, error) do |connection|
566
- connection.get( url.path + (url.query ? ('?' + url.query) : ''),
567
- HTTP_HEADERS.merge(headers) )
568
- end
569
- end
570
-
571
- def request(url, response_handler, error, &connector)
572
- response = nil
573
- Timeout.timeout(5 * 60, APITimeoutError) do
574
-
575
- # take care of http/https proxy, if specified in command line options
576
- # Given a blank string, the proxy_uri URI instance's host/port/user/pass will be nil
577
- # Net::HTTP::Proxy returns a regular Net::HTTP class if the first argument (host) is nil
578
- proxy_uri = URI.parse(url.is_a?(URI::HTTPS) ? @https_proxy : @http_proxy)
579
- http=Net::HTTP::Proxy(proxy_uri.host,proxy_uri.port,proxy_uri.user,proxy_uri.port).new(url.host, url.port)
580
-
581
- if url.is_a? URI::HTTPS
582
- http.use_ssl = true
583
- http.ca_file = File.join( File.dirname(__FILE__),
584
- *%w[.. .. data cacert.pem] )
585
- http.verify_mode = OpenSSL::SSL::VERIFY_PEER |
586
- OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
587
- end
588
- response = no_warnings { http.start(&connector) }
589
- end
590
- case response
591
- when Net::HTTPSuccess, Net::HTTPNotModified
592
- response_handler[response] unless response_handler.nil?
593
- else
594
- error = "Server says: #{response['x-scout-msg']}" if response['x-scout-msg']
595
- fatal error
596
- raise SystemExit.new(error)
597
- end
598
- rescue Timeout::Error
599
- fatal "Request timed out."
600
- exit
601
- rescue Exception
602
- raise if $!.is_a? SystemExit
603
- fatal "An HTTP error occurred: #{$!.message}"
604
- exit
605
- end
606
-
607
529
  def checkin
608
530
  debug """
609
531
  #{PP.pp(@checkin, '')}
@@ -626,23 +548,6 @@ module Scout
626
548
  end
627
549
 
628
550
 
629
- def no_warnings
630
- old_verbose = $VERBOSE
631
- $VERBOSE = false
632
- yield
633
- ensure
634
- $VERBOSE = old_verbose
635
- end
636
-
637
- # Forward Logger methods to an active instance, when there is one.
638
- def method_missing(meth, *args, &block)
639
- if (Logger::SEV_LABEL - %w[ANY]).include? meth.to_s.upcase
640
- @logger.send(meth, *args, &block) unless @logger.nil?
641
- else
642
- super
643
- end
644
- end
645
-
646
551
  private
647
552
 
648
553
  # Called during initialization; loads the plugin_configs (local plugin configurations for passwords, etc)
@@ -0,0 +1,100 @@
1
+ require "net/https"
2
+ require "uri"
3
+ require "yaml"
4
+ require "timeout"
5
+ require "stringio"
6
+ require "zlib"
7
+ require "socket"
8
+ require "base64"
9
+
10
+ module Scout
11
+ class ServerBase
12
+ # A new class for plugin Timeout errors.
13
+ class PluginTimeoutError < RuntimeError; end
14
+ # A new class for API Timeout errors.
15
+ class APITimeoutError < RuntimeError; end
16
+
17
+ # Headers passed up with all API requests.
18
+ HTTP_HEADERS = { "Client-Version" => Scout::VERSION,
19
+ "Client-Hostname" => Socket.gethostname,
20
+ "Accept-Encoding" => "gzip" }
21
+
22
+
23
+ private
24
+
25
+ def urlify(url_name, options = Hash.new)
26
+ return unless @server
27
+ options.merge!(:client_version => Scout::VERSION)
28
+ URI.join(@server,
29
+ "/clients/CLIENT_KEY/#{url_name}.scout".
30
+ gsub(/\bCLIENT_KEY\b/, @client_key).
31
+ gsub(/\b[A-Z_]+\b/) { |k| options[k.downcase.to_sym] || k })
32
+ end
33
+
34
+ def post(url, error, body, headers = Hash.new, &response_handler)
35
+ return unless url
36
+ request(url, response_handler, error) do |connection|
37
+ post = Net::HTTP::Post.new(url.path +
38
+ (url.query ? ('?' + url.query) : ''),
39
+ HTTP_HEADERS.merge(headers))
40
+ post.body = body
41
+ connection.request(post)
42
+ end
43
+ end
44
+
45
+ def get(url, error, headers = Hash.new, &response_handler)
46
+ return unless url
47
+ request(url, response_handler, error) do |connection|
48
+ connection.get(url.path + (url.query ? ('?' + url.query) : ''),
49
+ HTTP_HEADERS.merge(headers))
50
+ end
51
+ end
52
+
53
+ def request(url, response_handler, error, &connector)
54
+ response = nil
55
+ Timeout.timeout(5 * 60, APITimeoutError) do
56
+ http = Net::HTTP.new(url.host, url.port)
57
+ if url.is_a? URI::HTTPS
58
+ http.use_ssl = true
59
+ http.ca_file = File.join(File.dirname(__FILE__),
60
+ *%w[.. .. data cacert.pem])
61
+ http.verify_mode = OpenSSL::SSL::VERIFY_PEER |
62
+ OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
63
+ end
64
+ response = no_warnings { http.start(&connector) }
65
+ end
66
+ case response
67
+ when Net::HTTPSuccess, Net::HTTPNotModified
68
+ response_handler[response] unless response_handler.nil?
69
+ else
70
+ error = "Server says: #{response['x-scout-msg']}" if response['x-scout-msg']
71
+ fatal error
72
+ raise SystemExit.new(error)
73
+ end
74
+ rescue Timeout::Error
75
+ fatal "Request timed out."
76
+ exit
77
+ rescue Exception
78
+ raise if $!.is_a? SystemExit
79
+ fatal "An HTTP error occurred: #{$!.message}"
80
+ exit
81
+ end
82
+
83
+ def no_warnings
84
+ old_verbose = $VERBOSE
85
+ $VERBOSE = false
86
+ yield
87
+ ensure
88
+ $VERBOSE = old_verbose
89
+ end
90
+
91
+ # Forward Logger methods to an active instance, when there is one.
92
+ def method_missing(meth, *args, &block)
93
+ if (Logger::SEV_LABEL - %w[ANY]).include? meth.to_s.upcase
94
+ @logger.send(meth, *args, &block) unless @logger.nil?
95
+ else
96
+ super
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,162 @@
1
+ require 'rubygems'
2
+ require 'json'
3
+
4
+ module Scout
5
+ class Streamer < Scout::ServerBase
6
+ MAX_DURATION = 60*30 # will shut down automatically after this many seconds
7
+ SLEEP = 1
8
+
9
+ # * history_file is the *path* to the history file
10
+ # * plugin_ids is an array of integers
11
+ def initialize(server, client_key, history_file, plugin_ids, streaming_key, logger = nil)
12
+ @server = server
13
+ @client_key = client_key
14
+ @history_file = history_file
15
+ @history = Hash.new
16
+ @logger = logger
17
+
18
+ @plugins = []
19
+
20
+ Pusher.app_id = '11495'
21
+ Pusher.key = 'a95aa7293cd158100246'
22
+ Pusher.secret = '9c13ccfe325fe3ae682d'
23
+
24
+ debug "plugin_ids = #{plugin_ids.inspect}"
25
+ debug "streaming_key = #{streaming_key}"
26
+
27
+ streamer_start_time = Time.now
28
+
29
+ hostname=Socket.gethostname
30
+ # load history
31
+ load_history
32
+
33
+ # get the array of plugins, AKA the plugin plan
34
+ @plugin_plan = Array(@history["old_plugins"])
35
+
36
+ # iterate through the plan and compile each plugin. We only compile plugins once at the beginning of the run
37
+ @plugin_plan.each do |plugin|
38
+ begin
39
+ compile_plugin(plugin) # this is what adds to the @plugin array
40
+ rescue Exception
41
+ error("Encountered an error: #{$!.message}")
42
+ puts $!.backtrace.join('\n')
43
+ end
44
+ end
45
+
46
+ # main loop. Continue running until global $continue_streaming is set to false OR we've been running for MAX DURATION
47
+ while(streamer_start_time+MAX_DURATION > Time.now && $continue_streaming) do
48
+ plugins=[]
49
+ @plugins.each_with_index do |plugin,i|
50
+ # ignore plugins whose ids are not in the plugin_ids array -- this also ignores local plugins
51
+ next if !(@plugin_plan[i]['id'] && plugin_ids.include?(@plugin_plan[i]['id'].to_i))
52
+ start_time=Time.now
53
+ plugin.reset!
54
+ plugin.run
55
+ duration=((Time.now-start_time)*1000).to_i
56
+
57
+ plugins << {:duration=>duration,
58
+ :fields=>plugin.reports.inject{|memo,hash|memo.merge(hash)},
59
+ :name=>@plugin_plan[i]["name"],
60
+ :id=>@plugin_plan[i]["id"]}
61
+ end
62
+
63
+ bundle={:hostname=>hostname,
64
+ :server_time=>Time.now.strftime("%I:%M:%S %p"),
65
+ :num_processes=>`ps -e | wc -l`.chomp.to_i,
66
+ :plugins=>plugins }
67
+
68
+ begin
69
+ Pusher[streaming_key].trigger!('server_data', bundle)
70
+ rescue Pusher::Error => e
71
+ # (Pusher::AuthenticationError, Pusher::HTTPError, or Pusher::Error)
72
+ error "Error pushing data: #{e.message}"
73
+ end
74
+
75
+ if false
76
+ # debugging
77
+ File.open(File.join(File.dirname(@history_file),"debug.txt"),"w") do |f|
78
+ f.puts "... sleeping @ #{Time.now.strftime("%I:%M:%S %p")}..."
79
+ f.puts bundle.to_yaml
80
+ end
81
+ end
82
+
83
+ sleep(SLEEP)
84
+ end
85
+ end
86
+
87
+
88
+ private
89
+
90
+ #def post_bundle(bundle)
91
+ # post( urlify(:stream),
92
+ # "Unable to stream to server.",
93
+ # bundle.to_json,
94
+ # "Content-Type" => "application/json")
95
+ #rescue Exception
96
+ # error "Unable to stream to server."
97
+ # debug $!.class.to_s
98
+ # debug $!.message
99
+ # debug $!.backtrace.join("\n")
100
+ #end
101
+
102
+ # sets up the @plugins array
103
+ def compile_plugin(plugin)
104
+ plugin_id = plugin['id']
105
+
106
+ # take care of plugin overrides
107
+ local_path = File.join(File.dirname(@history_file), "#{plugin_id}.rb")
108
+ if File.exist?(local_path)
109
+ code_to_run = File.read(local_path)
110
+ else
111
+ code_to_run=plugin['code'] || ""
112
+ end
113
+
114
+ id_and_name = "#{plugin['id']}-#{plugin['name']}".sub(/\A-/, "")
115
+ last_run = @history["last_runs"][id_and_name] ||
116
+ @history["last_runs"][plugin['name']]
117
+ memory = @history["memory"][id_and_name] ||
118
+ @history["memory"][plugin['name']]
119
+ options=(plugin['options'] || Hash.new)
120
+ options.merge!(:tuner_days=>"")
121
+ code_class=Plugin.extract_code_class(code_to_run)
122
+ begin
123
+ eval(code_to_run, TOPLEVEL_BINDING, plugin['path'] || plugin['name'] )
124
+ klass=Plugin.const_get(code_class)
125
+ info "Added a #{klass.name} plugin, id = #{plugin_id}"
126
+ @plugins << klass.load(last_run, (memory || Hash.new), options)
127
+
128
+ # turn certain methods into null-ops, so summaries aren't generated. Note that this is ad-hoc, and not future-proof.
129
+ if klass.name=="RailsRequests"; def klass.analyze;end;end
130
+ if klass.name=="ApacheAnalyzer"; def klass.generate_log_analysis;end;end
131
+
132
+ rescue Exception
133
+ error "Plugin would not compile: #{$!.message}"
134
+ end
135
+ end
136
+
137
+
138
+ def load_history
139
+ begin
140
+ debug "Loading history file..."
141
+ contents=File.read(@history_file)
142
+ @history = YAML.load(contents)
143
+ rescue => e
144
+ info "Couldn't load or parse the history file at #{@history_file}. Exiting."
145
+ exit(1)
146
+ end
147
+ info "History file loaded."
148
+ end
149
+
150
+ # Forward Logger methods to an active instance, when there is one.
151
+ def method_missing(meth, *args, &block)
152
+ if (Logger::SEV_LABEL - %w[ANY]).include? meth.to_s.upcase
153
+ @logger.send(meth, *args, &block) unless @logger.nil?
154
+ else
155
+ super
156
+ end
157
+ end
158
+
159
+ def growl(message)`growlnotify -m '#{message.gsub("'","\'")}'`;end
160
+
161
+ end
162
+ end