rails-pipeline 1.1.1
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.
- 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
|
+
}
|