rails-pipeline 1.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +227 -0
- data/Rakefile +27 -0
- data/bin/pipeline +138 -0
- data/bin/redis-to-ironmq.rb +20 -0
- data/lib/rails-pipeline.rb +34 -0
- data/lib/rails-pipeline/emitter.rb +121 -0
- data/lib/rails-pipeline/handlers/activerecord_crud.rb +35 -0
- data/lib/rails-pipeline/handlers/base_handler.rb +19 -0
- data/lib/rails-pipeline/handlers/logger.rb +13 -0
- data/lib/rails-pipeline/ironmq_publisher.rb +37 -0
- data/lib/rails-pipeline/ironmq_pulling_subscriber.rb +96 -0
- data/lib/rails-pipeline/ironmq_subscriber.rb +21 -0
- data/lib/rails-pipeline/pipeline_version.rb +40 -0
- data/lib/rails-pipeline/protobuf/encrypted_message.pb.rb +37 -0
- data/lib/rails-pipeline/protobuf/encrypted_message.proto +18 -0
- data/lib/rails-pipeline/redis_forwarder.rb +207 -0
- data/lib/rails-pipeline/redis_ironmq_forwarder.rb +12 -0
- data/lib/rails-pipeline/redis_publisher.rb +71 -0
- data/lib/rails-pipeline/sns_publisher.rb +62 -0
- data/lib/rails-pipeline/subscriber.rb +185 -0
- data/lib/rails-pipeline/symmetric_encryptor.rb +127 -0
- data/lib/rails-pipeline/version.rb +3 -0
- data/lib/tasks/rails-pipeline_tasks.rake +4 -0
- data/spec/emitter_spec.rb +141 -0
- data/spec/handlers/activerecord_crud_spec.rb +100 -0
- data/spec/handlers/logger_spec.rb +42 -0
- data/spec/ironmp_pulling_subscriber_spec.rb +98 -0
- data/spec/ironmq_publisher_spec.rb +37 -0
- data/spec/pipeline_version_spec.rb +35 -0
- data/spec/redis_forwarder_spec.rb +99 -0
- data/spec/redis_publisher_spec.rb +36 -0
- data/spec/sns_publisher_spec.rb +28 -0
- data/spec/subscriber_spec.rb +278 -0
- data/spec/symmetric_encryptor_spec.rb +21 -0
- 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
|
+
}
|