rails-pipeline 1.1.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +227 -0
  4. data/Rakefile +27 -0
  5. data/bin/pipeline +138 -0
  6. data/bin/redis-to-ironmq.rb +20 -0
  7. data/lib/rails-pipeline.rb +34 -0
  8. data/lib/rails-pipeline/emitter.rb +121 -0
  9. data/lib/rails-pipeline/handlers/activerecord_crud.rb +35 -0
  10. data/lib/rails-pipeline/handlers/base_handler.rb +19 -0
  11. data/lib/rails-pipeline/handlers/logger.rb +13 -0
  12. data/lib/rails-pipeline/ironmq_publisher.rb +37 -0
  13. data/lib/rails-pipeline/ironmq_pulling_subscriber.rb +96 -0
  14. data/lib/rails-pipeline/ironmq_subscriber.rb +21 -0
  15. data/lib/rails-pipeline/pipeline_version.rb +40 -0
  16. data/lib/rails-pipeline/protobuf/encrypted_message.pb.rb +37 -0
  17. data/lib/rails-pipeline/protobuf/encrypted_message.proto +18 -0
  18. data/lib/rails-pipeline/redis_forwarder.rb +207 -0
  19. data/lib/rails-pipeline/redis_ironmq_forwarder.rb +12 -0
  20. data/lib/rails-pipeline/redis_publisher.rb +71 -0
  21. data/lib/rails-pipeline/sns_publisher.rb +62 -0
  22. data/lib/rails-pipeline/subscriber.rb +185 -0
  23. data/lib/rails-pipeline/symmetric_encryptor.rb +127 -0
  24. data/lib/rails-pipeline/version.rb +3 -0
  25. data/lib/tasks/rails-pipeline_tasks.rake +4 -0
  26. data/spec/emitter_spec.rb +141 -0
  27. data/spec/handlers/activerecord_crud_spec.rb +100 -0
  28. data/spec/handlers/logger_spec.rb +42 -0
  29. data/spec/ironmp_pulling_subscriber_spec.rb +98 -0
  30. data/spec/ironmq_publisher_spec.rb +37 -0
  31. data/spec/pipeline_version_spec.rb +35 -0
  32. data/spec/redis_forwarder_spec.rb +99 -0
  33. data/spec/redis_publisher_spec.rb +36 -0
  34. data/spec/sns_publisher_spec.rb +28 -0
  35. data/spec/subscriber_spec.rb +278 -0
  36. data/spec/symmetric_encryptor_spec.rb +21 -0
  37. metadata +175 -0
@@ -0,0 +1,121 @@
1
+ # A Pipeline emitter is an active record model that, when changed,
2
+ # will publish the changed fields to some sort of queue
3
+
4
+ require "rails-pipeline/symmetric_encryptor"
5
+
6
+ module RailsPipeline
7
+ module Emitter
8
+
9
+ def self.included(base)
10
+ RailsPipeline::SymmetricEncryptor.included(base)
11
+ base.send :include, InstanceMethods
12
+ base.extend ClassMethods
13
+ base.after_commit :emit_on_create, on: :create, if: :persisted?
14
+ base.after_commit :emit_on_update, on: :update
15
+ base.after_commit :emit_on_destroy, on: :destroy
16
+
17
+ if RailsPipeline::HAS_NEWRELIC
18
+ base.send :include, ::NewRelic::Agent::MethodTracer
19
+ base.add_method_tracer :emit, 'Pipeline/Emitter/emit'
20
+ end
21
+ end
22
+
23
+ module InstanceMethods
24
+ def emit_on_create
25
+ emit(:create)
26
+ end
27
+
28
+ def emit_on_update
29
+ emit(:update)
30
+ end
31
+
32
+ def emit_on_destroy
33
+ emit(:destroy)
34
+ end
35
+
36
+ def emit(event_type = RailsPipeline::EncryptedMessage::EventType::CREATED)
37
+ if ENV.has_key?("DISABLE_RAILS_PIPELINE") || ENV.has_key?("DISABLE_RAILS_PIPELINE_EMISSION")
38
+ RailsPipeline.logger.debug "Skipping outgoing pipeline messages (disabled by env vars)"
39
+ return
40
+ end
41
+ begin
42
+ self.class.pipeline_versions.each do |version|
43
+ enc_data = create_message(version, event_type)
44
+ self.publish(enc_data.topic, enc_data.to_s)
45
+ end
46
+ rescue Exception => e
47
+ RailsPipeline.logger.error("Error during emit(): #{e}")
48
+ puts e.backtrace.join("\n")
49
+ raise e
50
+ end
51
+ end
52
+
53
+ def create_message(version, event_type)
54
+ topic = self.class.topic_name(version)
55
+ RailsPipeline.logger.debug "Emitting to #{topic}"
56
+ data = self.send("to_pipeline_#{version}")
57
+ enc_data = self.class.encrypt(data.to_s, type_info: data.class.name, topic: topic, event_type: event_type)
58
+ return enc_data
59
+ end
60
+
61
+ end
62
+
63
+ module ClassMethods
64
+ # Get the list of versions to emit (all that are implemented, basically)
65
+ def pipeline_versions
66
+ if pipeline_method_cache.any?
67
+ return pipeline_method_cache.keys
68
+ end
69
+ versions = []
70
+ pipeline_methods = instance_methods.grep(/^to_pipeline/).sort
71
+ pipeline_methods.each do |pipeline_method|
72
+ version = pipeline_method.to_s.gsub("to_pipeline_", "")
73
+ if version.starts_with?("1_") && version != "1_0"
74
+ # Delete the default v1.0 emitter if a later v1 emitter is defined
75
+ i = versions.index("1_0")
76
+ versions.delete_at(i) if !i.nil?
77
+ end
78
+ pipeline_method_cache[version] = pipeline_method
79
+ versions << version
80
+ end
81
+ return versions
82
+ end
83
+
84
+ # Get pub/sub topic name to which changes to this model will be published
85
+ def topic_name(version="1_0")
86
+ return "harrys-#{Rails.env}-v#{major(version)}-#{table_name}"
87
+ end
88
+
89
+ # Get the major version number from a "1_1" style major/minor version string
90
+ def major(version)
91
+ if version.include? '_'
92
+ major, _ = version.split('_', 2)
93
+ else
94
+ major = version
95
+ end
96
+ return major
97
+ end
98
+
99
+ def _secret
100
+ ENV.fetch("PIPELINE_SECRET", Rails.application.config.secret_token)
101
+ end
102
+
103
+ def pipeline_method_cache
104
+ @pipeline_method_cache ||= {}
105
+ end
106
+
107
+ def pipeline_method_cache=(cache)
108
+ @pipeline_method_cache = cache
109
+ end
110
+
111
+ end
112
+ end
113
+
114
+
115
+ module RedisEmitter
116
+ def self.included(base)
117
+ RailsPipeline::Emitter.included(base)
118
+ RailsPipeline::RedisPublisher.included(base)
119
+ end
120
+ end
121
+ end
@@ -0,0 +1,35 @@
1
+ module RailsPipeline
2
+ module SubscriberHandler
3
+ class ActiveRecordCRUD < BaseHandler
4
+ def handle_payload
5
+ begin
6
+ case event_type
7
+ when RailsPipeline::EncryptedMessage::EventType::CREATED
8
+ return target_class.create!(_attributes(payload), without_protection: true)
9
+ when RailsPipeline::EncryptedMessage::EventType::UPDATED
10
+ # We might want to allow confiugration of the primary key field
11
+ object = target_class.find(payload.id)
12
+ object.update_attributes!(_attributes(payload), without_protection: true)
13
+ return object
14
+ when RailsPipeline::EncryptedMessage::EventType::DELETED
15
+ object = target_class.find(payload.id)
16
+ object.destroy
17
+ return object
18
+ end
19
+ rescue ActiveRecord::StatementInvalid, ActiveRecord::RecordNotFound => e
20
+ RailsPipeline.logger.error "Could not handle payload: #{payload.inspect}, event_type: #{event_type}"
21
+ end
22
+ end
23
+
24
+ def _attributes(payload)
25
+ attributes_hash = payload.to_hash
26
+ attributes_hash.each do |attribute_name, value|
27
+ if attribute_name.match /_at$/
28
+ attributes_hash[attribute_name] = Time.at(value).to_datetime
29
+ end
30
+ end
31
+ return attributes_hash
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,19 @@
1
+ module RailsPipeline
2
+ module SubscriberHandler
3
+ class BaseHandler
4
+ attr_reader :payload, :event_type, :target_class, :envelope
5
+
6
+ def initialize(payload, target_class: nil, envelope: nil, event_type: nil)
7
+ @payload = payload
8
+ @target_class = target_class
9
+ @envelope = envelope
10
+ if envelope
11
+ @event_type = envelope.event_type
12
+ else
13
+ @event_type = event_type
14
+ end
15
+ end
16
+
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,13 @@
1
+ module RailsPipeline
2
+ module SubscriberHandler
3
+ class Logger < BaseHandler
4
+ def handle_payload
5
+ # We'll need to GPG encrypt this
6
+ # Maybe it should encrypt using a public key set in environment variable
7
+ # Then the subscriber app would set it when installing the pipeline
8
+ # Would need to add configuration for that
9
+ RailsPipeline.logger.info envelope.to_s
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,37 @@
1
+ require 'base64'
2
+ require 'iron_mq'
3
+
4
+ # Backend for data pipeline that publishes to IronMQ
5
+ #
6
+ # Assumes the following env vars:
7
+ # - IRON_TOKEN=MY_TOKEN
8
+ # - IRON_PROJECT_ID=MY_PROJECT_ID
9
+
10
+ module RailsPipeline::IronmqPublisher
11
+
12
+ def self.included(base)
13
+ base.send :include, InstanceMethods
14
+ base.extend ClassMethods
15
+ end
16
+
17
+ module InstanceMethods
18
+ def publish(topic_name, data)
19
+ t0 = Time.now
20
+ queue = _iron.queue(topic_name)
21
+ queue.post({payload: Base64.strict_encode64(data)}.to_json)
22
+ t1 = Time.now
23
+ ::NewRelic::Agent.record_metric('Pipeline/IronMQ/publish', t1-t0) if RailsPipeline::HAS_NEWRELIC
24
+ RailsPipeline.logger.debug "Publishing to IronMQ: #{topic_name} took #{t1-t0}s"
25
+ end
26
+
27
+ def _iron
28
+ @iron = IronMQ::Client.new if @iron.nil?
29
+ return @iron
30
+ end
31
+
32
+ end
33
+
34
+ module ClassMethods
35
+ end
36
+
37
+ end
@@ -0,0 +1,96 @@
1
+ require 'base64'
2
+ require 'json'
3
+
4
+ module RailsPipeline
5
+ class IronmqPullingSubscriber
6
+ include RailsPipeline::Subscriber
7
+
8
+ attr_reader :queue_name
9
+
10
+ def initialize(queue_name)
11
+ @queue_name = queue_name
12
+ @subscription_status = false
13
+ end
14
+
15
+ # Valid Parameters at this time are
16
+ # wait_time - An integer indicating how long in seconds we should long poll on empty queues
17
+ # halt_on_error - A boolean indicating if we should stop our queue subscription if an error occurs
18
+ def start_subscription(params={wait_time: 2, halt_on_error: true}, &block)
19
+ activate_subscription
20
+
21
+ while active_subscription?
22
+ pull_message(params[:wait_time]) do |message|
23
+ process_message(message, params[:halt_on_error], block)
24
+ end
25
+ end
26
+ end
27
+
28
+
29
+ def process_message(message, halt_on_error, block)
30
+ begin
31
+ if message.nil? || JSON.parse(message.body).empty?
32
+ deactivate_subscription
33
+ else
34
+ payload = parse_ironmq_payload(message.body)
35
+ envelope = generate_envelope(payload)
36
+
37
+ process_envelope(envelope, message, block)
38
+ end
39
+ rescue Exception => e
40
+ if halt_on_error
41
+ deactivate_subscription
42
+ end
43
+
44
+ RailsPipeline.logger.error "A message was unable to be processed as was not removed from the queue."
45
+ RailsPipeline.logger.error "The message: #{message.inspect}"
46
+ raise e
47
+ end
48
+ end
49
+
50
+ def active_subscription?
51
+ @subscription_status
52
+ end
53
+
54
+ def activate_subscription
55
+ @subscription_status = true
56
+ end
57
+
58
+ def deactivate_subscription
59
+ @subscription_status = false
60
+ end
61
+
62
+ def process_envelope(envelope, message, block)
63
+ callback_status = block.call(envelope)
64
+
65
+ if callback_status
66
+ message.delete
67
+ end
68
+ end
69
+
70
+
71
+ #the wait time on this may need to be changed
72
+ #haven't seen rate limit info on these calls but didnt look
73
+ #all that hard either.
74
+ def pull_message(wait_time)
75
+ queue = _iron.queue(queue_name)
76
+ yield queue.get(:wait => wait_time)
77
+ end
78
+
79
+ private
80
+
81
+ def _iron
82
+ @iron = IronMQ::Client.new if @iron.nil?
83
+ return @iron
84
+ end
85
+
86
+ def parse_ironmq_payload(message_body)
87
+ payload = JSON.parse(message_body)["payload"]
88
+ Base64.strict_decode64(payload)
89
+ end
90
+
91
+ def generate_envelope(payload)
92
+ RailsPipeline::EncryptedMessage.parse(payload)
93
+ end
94
+
95
+ end
96
+ end
@@ -0,0 +1,21 @@
1
+ # Sinatra endpoints for consuming IronMQ push queues for the rails-pipeline
2
+
3
+ require 'sinatra'
4
+ require 'base64'
5
+
6
+ module RailsPipeline
7
+ class IronmqSubscriber < Sinatra::Base
8
+ include RailsPipeline::Subscriber
9
+
10
+ post '/' do
11
+ t0 = Time.now
12
+ data = request.body.read
13
+ payload = JSON.parse(Base64.strict_decode64(data))['payload']
14
+ envelope = RailsPipeline::EncryptedMessage.parse(payload)
15
+ handle_envelope(envelope)
16
+ t1 = Time.now
17
+ RailsPipeline.logger.debug "Consuming from IronMQ: #{envelope.topic} took #{t1-t0}s"
18
+ end
19
+
20
+ end
21
+ end
@@ -0,0 +1,40 @@
1
+ module RailsPipeline
2
+ # A thin wrapper around our version object
3
+ # A version has the form X_Y where
4
+ # - X is the major version
5
+ # - Y is the minor version
6
+ #
7
+ # example: 1_0, 2_1, etc...
8
+ class PipelineVersion
9
+
10
+ include Comparable
11
+
12
+ attr_reader :major, :minor
13
+
14
+ def initialize(version_string)
15
+ # raise error?
16
+ @major, @minor = version_string.split('_').map(&:to_i)
17
+ end
18
+
19
+ def to_s
20
+ "#{major}_#{minor}"
21
+ end
22
+
23
+ def <=>(other)
24
+ if major == other.major
25
+ return minor <=> other.minor
26
+ else
27
+ return major <=> other.major
28
+ end
29
+ end
30
+
31
+ def eql?(other)
32
+ return to_s.eql?(other.to_s)
33
+ end
34
+
35
+ def hash
36
+ return to_s.hash
37
+ end
38
+
39
+ end
40
+ end
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env ruby
2
+ # Generated by the protocol buffer compiler. DO NOT EDIT!
3
+
4
+ require 'protocol_buffers'
5
+
6
+ module RailsPipeline
7
+ # forward declarations
8
+ class EncryptedMessage < ::ProtocolBuffers::Message; end
9
+
10
+ class EncryptedMessage < ::ProtocolBuffers::Message
11
+ # forward declarations
12
+
13
+ # enums
14
+ module EventType
15
+ include ::ProtocolBuffers::Enum
16
+
17
+ set_fully_qualified_name "RailsPipeline.EncryptedMessage.EventType"
18
+
19
+ CREATED = 0
20
+ UPDATED = 1
21
+ DELETED = 2
22
+ end
23
+
24
+ set_fully_qualified_name "RailsPipeline.EncryptedMessage"
25
+
26
+ required :string, :uuid, 1
27
+ required :string, :salt, 2
28
+ required :string, :iv, 3
29
+ required :string, :ciphertext, 4
30
+ optional :string, :owner_info, 5
31
+ optional :string, :type_info, 6
32
+ optional :string, :topic, 7
33
+ optional ::RailsPipeline::EncryptedMessage::EventType, :event_type, 8, :default => ::RailsPipeline::EncryptedMessage::EventType::CREATED
34
+ required :string, :api_key, 9
35
+ end
36
+
37
+ end
@@ -0,0 +1,18 @@
1
+ package RailsPipeline;
2
+
3
+ message EncryptedMessage {
4
+ required string uuid = 1;
5
+ required string salt = 2;
6
+ required string iv = 3;
7
+ required string ciphertext = 4;
8
+ optional string owner_info = 5; // e.g. user_id, if you store a key on the User
9
+ optional string type_info = 6; // ruby class name
10
+ optional string topic = 7; // useful for redis message forwarder
11
+ enum EventType {
12
+ CREATED = 0;
13
+ UPDATED = 1;
14
+ DELETED = 2;
15
+ }
16
+ optional EventType event_type = 8 [default = CREATED]; // indicates what kind of event this payload contains
17
+ required string api_key = 9;
18
+ }