Skip to content

Commit 6a670a8

Browse files
committed
Commit
1 parent 396f47d commit 6a670a8

16 files changed

+352
-8
lines changed

lambda_on_rails.gemspec

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,9 @@ Gem::Specification.new do |spec|
2727

2828
spec.add_dependency "rails", "~> 5.2.2"
2929
spec.add_dependency "aws-sdk-sqs"
30+
spec.add_dependency "aws-sdk-lambda"
31+
spec.add_dependency "rubyzip"
3032
spec.add_development_dependency "sqlite3", "~> 1.3.6"
3133
spec.add_development_dependency "dotenv-rails"
34+
spec.add_development_dependency "pry"
3235
end

lib/active_job/queue_adapters/lambda_adapter.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
require 'active_job'
2+
13
module ActiveJob
24
module QueueAdapters
35
class LambdaAdapter

lib/lambda_on_rails.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
require 'lambda_on_rails/railtie'
22
require 'lambda_on_rails/job_runner'
33
require 'lambda_on_rails/queue_loader'
4+
require 'lambda_on_rails/job_uploader'
5+
require 'lambda_on_rails/job_packager'
46
require 'active_job/queue_adapters/lambda_adapter'
57

68
module LambdaOnRails

lib/lambda_on_rails/job_packager.rb

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
require_relative './job_wrapper'
2+
require 'zip'
3+
require 'tmpdir'
4+
5+
module LambdaOnRails
6+
class JobPackager
7+
def build_zip_file(job_klass)
8+
temp_directory = build_tmp_directory(job_klass)
9+
Zip::OutputStream.write_buffer do |io|
10+
Dir[File.join(temp_directory, '**', '*')].each do |file|
11+
next if File.directory?(file)
12+
13+
absolute_path = Pathname.new(file)
14+
relative_path = absolute_path.relative_path_from(Pathname.new(temp_directory))
15+
16+
io.put_next_entry(relative_path)
17+
io.write(File.read(absolute_path))
18+
end
19+
end
20+
end
21+
22+
private
23+
24+
def build_tmp_directory(job_klass)
25+
directory = Dir.mktmpdir(job_klass.to_s.gsub(/[^0-9a-z ]/i, '-'))
26+
FileUtils.cp(class_source_location(job_klass), directory)
27+
FileUtils.cp(job_wrapper_source_location, directory)
28+
29+
lambda_on_rails_path = '../../'
30+
31+
File.write(File.join(directory, 'Gemfile'),
32+
<<~GEMFILE
33+
source 'https://rubygems.org'
34+
35+
gem 'lambda_on_rails', path: "#{lambda_on_rails_path}"
36+
GEMFILE
37+
)
38+
39+
run_bundle_install(directory)
40+
41+
directory
42+
end
43+
44+
def class_source_location(klass)
45+
klass.instance_method(:perform).source_location[0]
46+
end
47+
48+
def class_source_code(klass)
49+
File.read(class_source_location(klass))
50+
end
51+
52+
def job_wrapper_source_location
53+
method(:handler).source_location[0]
54+
end
55+
56+
def job_wrapper_source_code
57+
File.read(job_wrapper_source_location)
58+
end
59+
60+
def run_bundle_install(directory)
61+
Bundler.with_clean_env do
62+
system("cd #{directory}; docker run -v `pwd`:`pwd` -w `pwd` -i -t lambci/lambda:build-ruby2.5 bundle install")
63+
system("cd #{directory}; docker run -v `pwd`:`pwd` -w `pwd` -i -t lambci/lambda:build-ruby2.5 bundle install --deployment")
64+
# system("cd #{directory}; bundle install")
65+
# system("cd #{directory}; bundle install --deployment")
66+
end
67+
end
68+
end
69+
end

lib/lambda_on_rails/job_runner.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ def initialize(queue_loader = ::LambdaOnRails::QueueLoader.new)
88

99
def enqueue(job)
1010
queue = queue_loader.get_queue(job.enqueue_url)
11-
queue.send_message(message_body: job.serialize.to_s)
11+
queue.send_message(message_body: Base64.encode64(job.serialize.to_json))
1212
end
1313
end
1414
end

lib/lambda_on_rails/job_uploader.rb

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
require_relative './job_wrapper'
2+
require 'aws-sdk-lambda'
3+
4+
module LambdaOnRails
5+
class JobUploader
6+
attr_reader :klass
7+
8+
def initialize(klass)
9+
@klass = klass
10+
end
11+
12+
def upload
13+
create_queue unless queue_exists?
14+
15+
if function_exists?
16+
update_lambda
17+
else
18+
create_lambda
19+
end
20+
21+
create_event_source_mapping unless event_source_mapping_exists?
22+
end
23+
24+
private
25+
26+
def function_exists?
27+
lambda_client.get_function(function_name: job_identifier)
28+
29+
true
30+
rescue Aws::Lambda::Errors::ResourceNotFoundException
31+
false
32+
end
33+
34+
def queue_exists?
35+
sqs_client.get_queue_url(queue_name: "#{job_identifier}-Incoming")
36+
37+
true
38+
rescue Aws::SQS::Errors::NonExistentQueue
39+
false
40+
end
41+
42+
def event_source_mapping_exists?
43+
lambda_client.list_event_source_mappings(
44+
function_name: job_identifier
45+
).event_source_mappings.any?
46+
end
47+
48+
def create_lambda
49+
lambda_client.create_function(
50+
function_name: job_identifier,
51+
runtime: 'ruby2.5',
52+
role: klass.role_arn,
53+
code: {
54+
zip_file: zip_file
55+
},
56+
handler: 'job_wrapper.handler'
57+
)
58+
end
59+
60+
def update_lambda
61+
lambda_client.update_function_code(
62+
function_name: job_identifier,
63+
zip_file: zip_file,
64+
publish: true
65+
)
66+
end
67+
68+
def create_queue
69+
sqs_client.create_queue(queue_name: "#{job_identifier}-Incoming")
70+
end
71+
72+
def create_event_source_mapping
73+
queue_url = sqs_client.get_queue_url(queue_name: "#{job_identifier}-Incoming").queue_url
74+
queue_attributes = sqs_client.get_queue_attributes(queue_url: queue_url, attribute_names: ["All"])
75+
76+
lambda_client.create_event_source_mapping(
77+
event_source_arn: queue_attributes.attributes['QueueArn'],
78+
function_name: job_identifier
79+
)
80+
end
81+
82+
def zip_file
83+
@zip_file ||= JobPackager.new.build_zip_file(klass).string
84+
end
85+
86+
def job_identifier
87+
@job_identifier ||= klass.to_s.gsub(/[^0-9a-z ]/i, '-')
88+
end
89+
90+
def lambda_client
91+
@lambda_client ||= Aws::Lambda::Client.new
92+
end
93+
94+
def sqs_client
95+
@sqs_client ||= Aws::SQS::Client.new
96+
end
97+
end
98+
end

lib/lambda_on_rails/job_wrapper.rb

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
load_paths = Dir["vendor/bundle/**/lib"]
2+
$LOAD_PATH.unshift(*load_paths)
3+
4+
require 'base64'
5+
6+
def handler(event:, context:)
7+
system("bundle install --deployment")
8+
9+
encoded_job = event['Records'][0]['body']
10+
serialized_job = JSON.parse(Base64.decode64(encoded_job))
11+
12+
require_relative serialized_job['job_class'].underscore
13+
14+
klass = Object.const_get(serialized_job['job_class'])
15+
16+
job = klass.new
17+
job.perform(*serialized_job['arguments'])
18+
end
19+
20+
class String
21+
def underscore
22+
word = self.dup
23+
word.gsub!(/::/, '/')
24+
word.gsub!(/([A-Z]+)([A-Z][a-z])/,'\1_\2')
25+
word.gsub!(/([a-z\d])([A-Z])/,'\1_\2')
26+
word.tr!("-", "_")
27+
word.downcase!
28+
word
29+
end
30+
end

lib/lambda_on_rails/railtie.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
module LambdaOnRails
22
class Railtie < ::Rails::Railtie
3+
rake_tasks do
4+
load 'tasks/lambda_on_rails_tasks.rake'
5+
end
36
end
47
end

lib/tasks/lambda_on_rails_tasks.rake

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
1-
# desc "Explaining what the task does"
2-
# task :lambda_on_rails do
3-
# # Task goes here
4-
# end
1+
2+
desc 'Uploads ActiveJob code to AWS'
3+
namespace :lambda_on_rails do
4+
task :upload, [:class_name] => :environment do |_task, args|
5+
job_class = args[:class_name].constantize
6+
job_uploader = LambdaOnRails::JobUploader.new(job_class)
7+
job_uploader.upload
8+
end
9+
end

test/dummy/app/jobs/sample_job.rb

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
1-
class SampleJob < ApplicationJob
1+
class SampleJob < ActiveJob::Base
22

33
self.queue_adapter = :lambda
44

5+
def perform(name)
6+
puts "Hello #{name}, how ya doin?"
7+
end
8+
59
def enqueue_url
6-
'https://sqs.us-east-1.amazonaws.com/567419588983/rails_active_job'
10+
'https://sqs.us-east-1.amazonaws.com/789533204773/SampleJob-Incoming'
11+
end
12+
13+
def self.role_arn
14+
'arn:aws:iam::789533204773:role/lambda_sqs_basic_execution'
715
end
816
end
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
require 'test_helper'
2+
require_relative './test_job'
3+
require 'zip'
4+
require 'aws-sdk-lambda'
5+
6+
class LambdaOnRails::JobPackager::Test < ActiveSupport::TestCase
7+
test 'creates a zip file with the source code' do
8+
job_packager = ::LambdaOnRails::JobPackager.new
9+
10+
job_packager.stub(:run_bundle_install, true, ['a']) do
11+
@zip_file = job_packager.build_zip_file(LambdaOnRails::TestJob)
12+
end
13+
14+
Zip::File.open_buffer(@zip_file) do |data|
15+
job = data.select { |entry| entry.name == 'test_job.rb' }.first
16+
assert(job.get_input_stream.read.include?('class LambdaOnRails::TestJob < ActiveJob::Base'))
17+
18+
job_wrapper = data.select { |entry| entry.name == 'job_wrapper.rb' }.first
19+
assert(job_wrapper.get_input_stream.read.include?('def handler'))
20+
end
21+
end
22+
end

test/lambda_on_rails/job_runner_test.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ class LambdaOnRails::JobRunner::Test < ActiveSupport::TestCase
88
true,
99
[
1010
{
11-
message_body: '{}'
11+
message_body: Base64.encode64({}.to_json)
1212
}
1313
]
1414
)
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
require 'test_helper'
2+
require_relative './test_job'
3+
require 'zip'
4+
require 'aws-sdk-lambda'
5+
6+
class LambdaOnRails::JobUploader::Test < ActiveSupport::TestCase
7+
test 'creates a Lambda function and SQS queue' do
8+
job_uploader = ::LambdaOnRails::JobUploader.new(LambdaOnRails::TestJob)
9+
10+
sqs_client = Minitest::Mock.new
11+
def sqs_client.get_queue_url(options)
12+
@count ||= 0
13+
@count += 1
14+
if @count > 1
15+
OpenStruct.new(queue_url: 'https://sqs')
16+
else
17+
raise Aws::SQS::Errors::NonExistentQueue.new(nil, nil)
18+
end
19+
end
20+
21+
sqs_client.expect :create_queue, OpenStruct.new(queue_url: 'https://sqs'), [{
22+
queue_name: 'LambdaOnRails--TestJob-Incoming'
23+
}]
24+
25+
sqs_client.expect :get_queue_attributes, OpenStruct.new(attributes: { 'QueueArn' => 'arn:test:queue' }), [{
26+
queue_url: 'https://sqs',
27+
attribute_names: ['All']
28+
}]
29+
30+
lambda_client = Minitest::Mock.new
31+
def lambda_client.get_function(options); raise Aws::Lambda::Errors::ResourceNotFoundException.new(nil, nil) end
32+
lambda_client.expect :create_function, nil, [{
33+
function_name: 'LambdaOnRails--TestJob',
34+
code: {
35+
zip_file: 'ABCD'
36+
},
37+
runtime: 'ruby2.5',
38+
role: 'role:12345/us-east1',
39+
handler: 'job_wrapper.handler'
40+
}]
41+
42+
lambda_client.expect :create_event_source_mapping, nil, [{
43+
event_source_arn: 'arn:test:queue',
44+
function_name: 'LambdaOnRails--TestJob'
45+
}]
46+
47+
lambda_client.expect :list_event_source_mappings, OpenStruct.new(event_source_mappings: []), [{
48+
function_name: 'LambdaOnRails--TestJob'
49+
}]
50+
51+
job_packager = Minitest::Mock.new
52+
job_packager.expect :build_zip_file, StringIO.new('ABCD'), [LambdaOnRails::TestJob]
53+
54+
class LambdaOnRails::TestJob
55+
def self.role_arn; 'role:12345/us-east1' end
56+
end
57+
58+
LambdaOnRails::JobPackager.stub(:new, job_packager) do
59+
Aws::SQS::Client.stub(:new, sqs_client) do
60+
Aws::Lambda::Client.stub(:new, lambda_client) do
61+
job_uploader.upload
62+
end
63+
end
64+
end
65+
66+
assert_mock job_packager
67+
assert_mock lambda_client
68+
assert_mock sqs_client
69+
end
70+
end

0 commit comments

Comments
 (0)