ryespy 0.7.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,79 @@
1
+ require 'net/imap'
2
+
3
+
4
+ module Ryespy
5
+ module Listener
6
+ class IMAP < Base
7
+
8
+ REDIS_KEY_PREFIX = 'imap'.freeze
9
+ SIDEKIQ_JOB_CLASS = 'RyespyIMAPJob'.freeze
10
+
11
+ def initialize(opts = {})
12
+ @imap_config = {
13
+ :host => opts[:host],
14
+ :port => opts[:port],
15
+ :ssl => opts[:ssl],
16
+ :username => opts[:username],
17
+ :password => opts[:password],
18
+ }
19
+
20
+ super(opts)
21
+ end
22
+
23
+ def close
24
+ @imap.disconnect
25
+ end
26
+
27
+ def check(mailbox)
28
+ @logger.debug { "mailbox: #{mailbox}" }
29
+
30
+ @logger.debug { "redis_key: #{redis_key(mailbox)}" }
31
+
32
+ last_seen_uid = @redis.get(redis_key(mailbox)).to_i
33
+
34
+ unseen_uids = get_unseen_uids(mailbox, last_seen_uid)
35
+
36
+ @logger.debug { "unseen_uids: #{unseen_uids}" }
37
+
38
+ unseen_uids.each do |uid|
39
+ @redis.set(redis_key(mailbox), uid)
40
+
41
+ @notifiers.each { |n| n.notify(SIDEKIQ_JOB_CLASS, [mailbox, uid]) }
42
+ end
43
+
44
+ @logger.info { "#{mailbox} has #{unseen_uids.count} new emails" }
45
+ end
46
+
47
+ private
48
+
49
+ def connect_service
50
+ @imap = Net::IMAP.new(@imap_config[:host], {
51
+ :port => @imap_config[:port],
52
+ :ssl => @imap_config[:ssl],
53
+ })
54
+
55
+ @imap.login(@imap_config[:username], @imap_config[:password])
56
+ end
57
+
58
+ def redis_key(mailbox)
59
+ [
60
+ REDIS_KEY_PREFIX,
61
+ @imap_config[:host],
62
+ @imap_config[:port],
63
+ @imap_config[:username],
64
+ mailbox,
65
+ ].join(':')
66
+ end
67
+
68
+ def get_unseen_uids(mailbox, last_seen_uid = nil)
69
+ @imap.select(mailbox)
70
+
71
+ uids = @imap.uid_search("#{last_seen_uid + 1}:*")
72
+
73
+ # filter as IMAP search gets fun with edge cases
74
+ uids.find_all { |uid| uid > last_seen_uid }
75
+ end
76
+
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,53 @@
1
+ require 'fog'
2
+
3
+ require_relative 'fogable'
4
+
5
+
6
+ module Ryespy
7
+ module Listener
8
+ class RaxCF < Base
9
+
10
+ include Listener::Fogable
11
+
12
+ REDIS_KEY_PREFIX = 'rax_cf'.freeze
13
+ SIDEKIQ_JOB_CLASS = 'RyespyRaxCFJob'.freeze
14
+
15
+ def initialize(opts = {})
16
+ @config = {
17
+ :auth_url => Fog::Rackspace.const_get(
18
+ "#{opts[:endpoint].upcase}_AUTH_ENDPOINT"
19
+ ),
20
+ :region => opts[:region].downcase.to_sym,
21
+ :username => opts[:username],
22
+ :api_key => opts[:api_key],
23
+ :directory => opts[:container],
24
+ }
25
+
26
+ super(opts)
27
+ end
28
+
29
+ private
30
+
31
+ def connect_service
32
+ @fog_storage = Fog::Storage.new({
33
+ :provider => 'Rackspace',
34
+ :rackspace_auth_url => @config[:auth_url],
35
+ :rackspace_region => @config[:region],
36
+ :rackspace_username => @config[:username],
37
+ :rackspace_api_key => @config[:api_key],
38
+ })
39
+ end
40
+
41
+ def redis_key
42
+ # CF container (directory) is unique across an account (region?).
43
+ [
44
+ REDIS_KEY_PREFIX,
45
+ @config[:username],
46
+ @config[:directory],
47
+ @config[:region],
48
+ ].join(':')
49
+ end
50
+
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,69 @@
1
+ require 'logger'
2
+ require 'redis'
3
+ require 'redis-namespace'
4
+ require 'json'
5
+ require 'securerandom'
6
+
7
+
8
+ module Ryespy
9
+ module Notifier
10
+ class Sidekiq
11
+
12
+ SIDEKIQ_QUEUE = 'ryespy'.freeze
13
+ SIDEKIQ_KEY_QUEUES = 'queues'.freeze
14
+ SIDEKIQ_KEY_QUEUE_X = "queue:#{SIDEKIQ_QUEUE}".freeze
15
+
16
+ def initialize(opts = {})
17
+ @redis_config = {
18
+ :url => opts[:url],
19
+ :namespace => opts[:namespace],
20
+ }
21
+
22
+ @logger = opts[:logger] || Logger.new(nil)
23
+
24
+ connect_redis
25
+
26
+ if block_given?
27
+ yield self
28
+
29
+ close
30
+ end
31
+ end
32
+
33
+ def close
34
+ @redis.quit
35
+ end
36
+
37
+ def notify(job_class, args)
38
+ @redis.sadd(SIDEKIQ_KEY_QUEUES, SIDEKIQ_QUEUE)
39
+
40
+ sidekiq_job_payload = sidekiq_job(job_class, args)
41
+
42
+ @logger.debug { "Setting Redis Key #{SIDEKIQ_KEY_QUEUE_X} Payload #{sidekiq_job_payload.to_json}" }
43
+
44
+ @redis.rpush(SIDEKIQ_KEY_QUEUE_X, sidekiq_job_payload.to_json)
45
+ end
46
+
47
+ private
48
+
49
+ def connect_redis
50
+ @redis = Redis::Namespace.new(@redis_config[:namespace],
51
+ :redis => Redis.connect(:url => @redis_config[:url])
52
+ )
53
+ end
54
+
55
+ def sidekiq_job(job_class, args)
56
+ {
57
+ # resque
58
+ :class => job_class,
59
+ :args => args,
60
+ # sidekiq (extra)
61
+ :queue => SIDEKIQ_QUEUE,
62
+ :retry => true,
63
+ :jid => SecureRandom.hex(12),
64
+ }
65
+ end
66
+
67
+ end
68
+ end
69
+ end
@@ -1,5 +1,5 @@
1
1
  module Ryespy
2
2
 
3
- VERSION = "0.7.0"
3
+ VERSION = '1.0.0'.freeze
4
4
 
5
5
  end
@@ -8,11 +8,8 @@ Gem::Specification.new do |spec|
8
8
  spec.version = Ryespy::VERSION
9
9
  spec.authors = ["tiredpixel"]
10
10
  spec.email = ["tp@tiredpixel.com"]
11
- spec.description = %q{Ryespy provides a simple executable for listening to
12
- IMAP mailboxes or FTP folders, keeps track of what it's seen using Redis,
13
- and notifies Redis in a way in which Resque and Sidekiq can process using
14
- workers.}
15
- spec.summary = %q{Ryespy listens to IMAP and FTP and queues in Redis (Sidekiq/Resque).}
11
+ spec.description = %q{Redis Sidekiq/Resque IMAP, FTP, Amazon S3, Google Cloud Storage, Rackspace Cloud Files listener.}
12
+ spec.summary = %q{Redis Sidekiq/Resque IMAP, FTP, Amazon S3, Google Cloud Storage, Rackspace Cloud Files listener.}
16
13
  spec.homepage = "https://github.com/tiredpixel/ryespy"
17
14
  spec.license = "MIT"
18
15
 
@@ -21,8 +18,12 @@ Gem::Specification.new do |spec|
21
18
  spec.test_files = spec.files.grep(%r{^(test|spec|features)/})
22
19
  spec.require_paths = ["lib"]
23
20
 
24
- spec.add_dependency "redis", "~> 3.0.4"
21
+ spec.add_dependency "redis", "~> 3.0"
22
+ spec.add_dependency "redis-namespace", "~> 1.4"
25
23
 
26
- spec.add_development_dependency "bundler", "~> 1.3"
24
+ spec.add_development_dependency "bundler", "~> 1.3", "!= 1.5.0"
27
25
  spec.add_development_dependency "rake"
26
+ spec.add_development_dependency "fog", "~> 1.19" # conditional dependency
27
+ spec.add_development_dependency "mocha", "~> 0.14"
28
+ spec.add_development_dependency "sidekiq-spy", "~> 0.3"
28
29
  end
@@ -0,0 +1,41 @@
1
+ require 'minitest/autorun'
2
+ require 'mocha/setup'
3
+ require 'securerandom'
4
+ require 'json'
5
+ require 'redis'
6
+ require 'redis-namespace'
7
+
8
+
9
+ module Ryespy
10
+ module Test
11
+
12
+ def self.config
13
+ @config ||= {
14
+ :redis => {
15
+ :url => ENV['REDIS_URL'], # defaults
16
+ :namespace => 'ryespy:test',
17
+ },
18
+ }
19
+ end
20
+
21
+ module Redis
22
+
23
+ def self.setup
24
+ ::Redis.current = ::Redis::Namespace.new(self.namespace,
25
+ :redis => ::Redis.connect(:url => Ryespy::Test.config[:redis][:url])
26
+ )
27
+ end
28
+
29
+ def self.namespace
30
+ "#{Ryespy::Test.config[:redis][:namespace]}:#{SecureRandom.hex}"
31
+ end
32
+
33
+ def self.flush_namespace(redis)
34
+ # Redis::Namespace means only namespaced keys removed
35
+ redis.keys('*').each { |k| redis.del(k) }
36
+ end
37
+
38
+ end
39
+
40
+ end
41
+ end
@@ -0,0 +1,348 @@
1
+ require 'logger'
2
+ require 'net/imap'
3
+
4
+
5
+ require_relative '../helper'
6
+
7
+ require_relative '../../lib/ryespy'
8
+
9
+
10
+ def start_and_stop_app(app)
11
+ app_thread = Thread.new { app.start }
12
+
13
+ sleep 1 # patience, patience; give app time to start
14
+
15
+ app.stop
16
+
17
+ app_thread.join(2)
18
+
19
+ Thread.kill(app_thread)
20
+ end
21
+
22
+
23
+ describe Ryespy::App do
24
+
25
+ describe "#initialize" do
26
+ before do
27
+ @app = Ryespy::App.new
28
+ end
29
+
30
+ it "defaults running to false" do
31
+ @app.running.must_equal false
32
+ end
33
+ end
34
+
35
+ describe "#configure" do
36
+ before do
37
+ @app = Ryespy::App.new
38
+
39
+ @config = @app.config
40
+ end
41
+
42
+ describe "main" do
43
+ before do
44
+ @app.configure do |c|
45
+ c.log_level = 'ERROR'
46
+ c.listener = 'imap'
47
+ c.polling_interval = 13
48
+ c.redis_url = 'redis://127.0.0.1:6379/1'
49
+ c.redis_ns_ryespy = 'WithMyLittleEye!'
50
+ c.redis_ns_notifiers = 'LaLaLiLi-'
51
+ c.notifiers = [{ :sidekiq => 'redis://127.0.0.1:6379/2' }]
52
+ end
53
+ end
54
+
55
+ it "configures log_level" do
56
+ @config.log_level.must_equal 'ERROR'
57
+ end
58
+
59
+ it "sets logger level" do
60
+ @app.instance_variable_get(:@logger).level.must_equal Logger::ERROR
61
+ end
62
+
63
+ it "configures listener" do
64
+ @config.listener.must_equal 'imap'
65
+ end
66
+
67
+ it "configures polling_interval" do
68
+ @config.polling_interval.must_equal 13
69
+ end
70
+
71
+ it "configures redis_url" do
72
+ @config.redis_url.must_equal 'redis://127.0.0.1:6379/1'
73
+ end
74
+
75
+ it "configures redis_ns_ryespy" do
76
+ @config.redis_ns_ryespy.must_equal 'WithMyLittleEye!'
77
+ end
78
+
79
+ it "configures redis_ns_notifiers" do
80
+ @config.redis_ns_notifiers.must_equal 'LaLaLiLi-'
81
+ end
82
+
83
+ it "configures notifiers" do
84
+ @config.notifiers.must_equal [{ :sidekiq => 'redis://127.0.0.1:6379/2' }]
85
+ end
86
+ end
87
+
88
+ describe "listener IMAP" do
89
+ before do
90
+ @app.configure do |c|
91
+ c.imap_host = 'imap.example.com'
92
+ c.imap_port = 143
93
+ c.imap_ssl = false
94
+ c.imap_username = 'lucy.westenra@example.com'
95
+ c.imap_password = 'white'
96
+ c.imap_filters = 'BoxA,Sent Messages'
97
+ end
98
+ end
99
+
100
+ it "configures imap_host" do
101
+ @config.imap_host.must_equal 'imap.example.com'
102
+ end
103
+
104
+ it "configures imap_port" do
105
+ @config.imap_port.must_equal 143
106
+ end
107
+
108
+ it "configures imap_ssl" do
109
+ @config.imap_ssl.must_equal false
110
+ end
111
+
112
+ it "configures imap_username" do
113
+ @config.imap_username.must_equal 'lucy.westenra@example.com'
114
+ end
115
+
116
+ it "configures imap_password" do
117
+ @config.imap_password.must_equal 'white'
118
+ end
119
+
120
+ it "configures imap_filters" do
121
+ @config.imap_filters.must_equal 'BoxA,Sent Messages'
122
+ end
123
+ end
124
+
125
+ describe "listener FTP" do
126
+ before do
127
+ @app.configure do |c|
128
+ c.ftp_host = 'ftp.example.org'
129
+ c.ftp_port = 2121
130
+ c.ftp_passive = true
131
+ c.ftp_username = 'madam.mina@example.com'
132
+ c.ftp_password = 'black'
133
+ c.ftp_filters = ['BoxA', 'Sent Messages']
134
+ end
135
+ end
136
+
137
+ it "configures ftp_host" do
138
+ @config.ftp_host.must_equal 'ftp.example.org'
139
+ end
140
+
141
+ it "configures ftp_port" do
142
+ @config.ftp_port.must_equal 2121
143
+ end
144
+
145
+ it "configures ftp_passive" do
146
+ @config.ftp_passive.must_equal true
147
+ end
148
+
149
+ it "configures ftp_username" do
150
+ @config.ftp_username.must_equal 'madam.mina@example.com'
151
+ end
152
+
153
+ it "configures ftp_password" do
154
+ @config.ftp_password.must_equal 'black'
155
+ end
156
+
157
+ it "configures ftp_filters" do
158
+ @config.ftp_filters.must_equal ['BoxA', 'Sent Messages']
159
+ end
160
+ end
161
+
162
+ describe "listener amzn-s3" do
163
+ before do
164
+ @app.configure do |c|
165
+ c.amzn_s3_access_key = 'r.m.renfield'
166
+ c.amzn_s3_secret_key = 'master'
167
+ c.amzn_s3_bucket = 'i-can-wait'
168
+ c.amzn_s3_filters = ['flies/', 'spiders/']
169
+ end
170
+ end
171
+
172
+ it "configures amzn_s3_access_key" do
173
+ @config.amzn_s3_access_key.must_equal 'r.m.renfield'
174
+ end
175
+
176
+ it "configures amzn_s3_secret_key" do
177
+ @config.amzn_s3_secret_key.must_equal 'master'
178
+ end
179
+
180
+ it "configures amzn_s3_bucket" do
181
+ @config.amzn_s3_bucket.must_equal 'i-can-wait'
182
+ end
183
+
184
+ it "configures amzn_s3_filters" do
185
+ @config.amzn_s3_filters.must_equal ["flies/", "spiders/"]
186
+ end
187
+ end
188
+
189
+ describe "listener goog-cs" do
190
+ before do
191
+ @app.configure do |c|
192
+ c.goog_cs_access_key = 'r.m.renfield'
193
+ c.goog_cs_secret_key = 'master'
194
+ c.goog_cs_bucket = 'i-can-wait'
195
+ c.goog_cs_filters = ['flies/', 'spiders/']
196
+ end
197
+ end
198
+
199
+ it "configures goog_cs_access_key" do
200
+ @config.goog_cs_access_key.must_equal 'r.m.renfield'
201
+ end
202
+
203
+ it "configures goog_cs_secret_key" do
204
+ @config.goog_cs_secret_key.must_equal 'master'
205
+ end
206
+
207
+ it "configures goog_cs_bucket" do
208
+ @config.goog_cs_bucket.must_equal 'i-can-wait'
209
+ end
210
+
211
+ it "configures goog_cs_filters" do
212
+ @config.goog_cs_filters.must_equal ["flies/", "spiders/"]
213
+ end
214
+ end
215
+
216
+ describe "listener rax-cf" do
217
+ before do
218
+ @app.configure do |c|
219
+ c.rax_cf_endpoint = 'uk'
220
+ c.rax_cf_region = 'lon'
221
+ c.rax_cf_username = 'van.helsing'
222
+ c.rax_cf_api_key = 'M.D., D.Ph., D.Litt., etc.'
223
+ c.rax_cf_container = 'the-milk-that-is-spilt-cries-not-out-afterwards'
224
+ c.rax_cf_filters = ['abraham/', 'van/']
225
+ end
226
+ end
227
+
228
+ it "configures rax_cf_endpoint" do
229
+ @config.rax_cf_endpoint.must_equal 'uk'
230
+ end
231
+
232
+ it "configures rax_cf_region" do
233
+ @config.rax_cf_region.must_equal 'lon'
234
+ end
235
+
236
+ it "configures rax_cf_username" do
237
+ @config.rax_cf_username.must_equal 'van.helsing'
238
+ end
239
+
240
+ it "configures rax_cf_api_key" do
241
+ @config.rax_cf_api_key.must_equal 'M.D., D.Ph., D.Litt., etc.'
242
+ end
243
+
244
+ it "configures rax_cf_container" do
245
+ @config.rax_cf_container.must_equal 'the-milk-that-is-spilt-cries-not-out-afterwards'
246
+ end
247
+
248
+ it "configures rax_cf_filters" do
249
+ @config.rax_cf_filters.must_equal ["abraham/", "van/"]
250
+ end
251
+ end
252
+ end
253
+
254
+ describe "#notifiers" do
255
+ before do
256
+ @app = Ryespy::App.new
257
+
258
+ @app.configure do |c|
259
+ c.notifiers = { :sidekiq => ['redis://127.0.0.1:6379/11'] }
260
+ end
261
+
262
+ @app.instance_variable_set(:@notifiers, nil)
263
+ end
264
+
265
+ it "creates notifiers when empty" do
266
+ @app.notifiers.map(&:class).must_equal [Ryespy::Notifier::Sidekiq]
267
+ end
268
+
269
+ it "returns notifiers when extant" do
270
+ @notifiers = stub
271
+
272
+ @app.instance_variable_set(:@notifiers, @notifiers)
273
+
274
+ @app.notifiers.must_equal @notifiers
275
+ end
276
+ end
277
+
278
+ describe "#start" do
279
+ before do
280
+ Net::IMAP.stubs(:new).returns(stub(
281
+ :login => nil,
282
+ :select => nil,
283
+ :uid_search => [],
284
+ :disconnect => nil
285
+ ))
286
+
287
+ @app = Ryespy::App.new(true)
288
+
289
+ @app.instance_variable_set(:@logger, Logger.new(nil))
290
+
291
+ @app.configure do |c|
292
+ c.listener = :imap
293
+ c.polling_interval = 10
294
+ end
295
+ end
296
+
297
+ it "sets status running within 1s" do
298
+ thread_app = Thread.new { @app.start }
299
+
300
+ sleep 1 # patience, patience; give app time to start
301
+
302
+ @app.running.must_equal true
303
+
304
+ Thread.kill(thread_app)
305
+ end
306
+
307
+ it "stops running within 1s" do
308
+ thread_app = Thread.new { @app.start }
309
+
310
+ sleep 1 # patience, patience; give app time to start
311
+
312
+ @app.stop; t0 = Time.now
313
+
314
+ thread_app.join(2)
315
+
316
+ Thread.kill(thread_app)
317
+
318
+ assert_operator (Time.now - t0), :<=, 1
319
+ end
320
+
321
+ it "calls #setup hook" do
322
+ @app.expects(:setup)
323
+
324
+ start_and_stop_app(@app)
325
+ end
326
+
327
+ it "calls #cleanup hook" do
328
+ @app.expects(:cleanup)
329
+
330
+ start_and_stop_app(@app)
331
+ end
332
+ end
333
+
334
+ describe "#stop" do
335
+ before do
336
+ @app = Ryespy::App.new
337
+
338
+ @app.instance_variable_set(:@running, true)
339
+ end
340
+
341
+ it "sets status not-running" do
342
+ @app.stop
343
+
344
+ @app.running.must_equal false
345
+ end
346
+ end
347
+
348
+ end