modulator 0.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.
@@ -0,0 +1,223 @@
1
+ require 'humidifier'
2
+ require_relative 'aws_stack_uploader'
3
+
4
+ module AwsStackBuilder
5
+ module_function
6
+
7
+ RUBY_VERSION = 'ruby2.5'
8
+ GEM_PATH = '/opt/ruby/2.5.0'
9
+ LAMBDA_HANDLER_FILE_NAME = 'lambda-handler'
10
+
11
+ class << self
12
+ attr_accessor :app_name, :stack, :api_gateway_deployment, :gateway_id, :app_path, :app_dir, :bucket, :stack_opts
13
+ end
14
+
15
+ def init(app_name:, bucket:, **stack_opts)
16
+ puts 'Initializing stack'
17
+ @app_name = app_name.camelize
18
+ @bucket = bucket
19
+ @app_path = Pathname.getwd
20
+ @app_dir = app_path.basename.to_s
21
+ @stack_opts = stack_opts
22
+ @stack = Humidifier::Stack.new(name: @app_name, aws_template_format_version: '2010-09-09')
23
+
24
+ # api stage
25
+ @stack.add_parameter('ApiGatewayStageName', description: 'Gateway deployment stage', type: 'String', default: 'v1')
26
+
27
+ add_api_gateway
28
+ add_api_gateway_deployment
29
+ add_lambda_iam_role
30
+ upload_files
31
+ extend_stack_instance(@stack)
32
+ @stack
33
+ end
34
+
35
+ def upload_files
36
+ upload_lambda_handler
37
+ puts 'Generating layers'
38
+ app_path.join('.modulator').mkpath
39
+ upload_gems_layer
40
+ upload_app_layer
41
+ end
42
+
43
+ # helpers
44
+ def extend_stack_instance(stack)
45
+ stack.instance_eval do
46
+ def add_lambda_endpoint(**opts) # gateway:, mod:, wrapper: {}, env: {}, settings: {}
47
+ # add lambda
48
+ lambda = AwsStackBuilder.add_lambda(opts)
49
+ # add api resources
50
+ AwsStackBuilder.add_api_gateway_resources(gateway: opts[:gateway], lambda: lambda)
51
+ end
52
+ end
53
+ end
54
+
55
+ # gateway
56
+ def add_api_gateway
57
+ @gateway_id = 'ApiGateway'
58
+ @stack.add(gateway_id, Humidifier::ApiGateway::RestApi.new(name: app_name, description: app_name + ' API'))
59
+ end
60
+
61
+ # gateway deployment
62
+ def add_api_gateway_deployment
63
+ @api_gateway_deployment = Humidifier::ApiGateway::Deployment.new(
64
+ rest_api_id: Humidifier.ref(gateway_id),
65
+ stage_name: Humidifier.ref("ApiGatewayStageName")
66
+ )
67
+ @stack.add('ApiGatewayDeployment', @api_gateway_deployment)
68
+ @stack.add_output('ApiGatewayInvokeURL',
69
+ value: Humidifier.fn.sub("https://${#{gateway_id}}.execute-api.${AWS::Region}.amazonaws.com/${ApiGatewayStageName}"),
70
+ description: 'API root url',
71
+ export_name: app_name + 'RootUrl'
72
+ )
73
+ @api_gateway_deployment.depends_on = []
74
+ end
75
+
76
+ # lambda function
77
+ def add_lambda(gateway:, mod:, wrapper: {}, env: {}, settings: {})
78
+ lambda_config = {}
79
+ name_parts = mod[:name].split('::')
80
+ {gateway: gateway, module: mod, wrapper: wrapper}.each do |env_group_prefix, env_group|
81
+ env_group.each{|env_key, env_value| lambda_config["#{env_group_prefix}_#{env_key}"] = env_value}
82
+ end
83
+
84
+ lambda_function = Humidifier::Lambda::Function.new(
85
+ description: "Lambda for #{mod[:name]}.#{mod[:method]}",
86
+ function_name: [app_name.dasherize, name_parts, mod[:method]].flatten.map(&:downcase).join('-'),
87
+ handler: "#{LAMBDA_HANDLER_FILE_NAME}.AwsLambdaHandler.call",
88
+ environment: {
89
+ variables: env
90
+ .reduce({}){|env_as_string, (k, v)| env_as_string.update(k.to_s => v.to_s)}
91
+ .merge(lambda_config)
92
+ .merge('GEM_PATH' => GEM_PATH, 'app_dir' => app_dir)
93
+ },
94
+ role: Humidifier.fn.get_att(['LambdaRole', 'Arn']),
95
+ timeout: settings[:timeout] || stack_opts[:timeout] || 15,
96
+ memory_size: settings[:memory_size] || stack_opts[:memory_size] || 128,
97
+ runtime: RUBY_VERSION,
98
+ code: {
99
+ s3_bucket: bucket,
100
+ s3_key: LAMBDA_HANDLER_FILE_NAME + '.rb.zip'
101
+ },
102
+ layers: [
103
+ Humidifier.ref(app_name + 'Layer'),
104
+ Humidifier.ref(app_name + 'GemsLayer')
105
+ ]
106
+ )
107
+ id = ['Lambda', name_parts, mod[:method].capitalize].join
108
+ @stack.add(id, lambda_function)
109
+ add_lambda_invoke_permission(id: id, gateway: gateway)
110
+ id
111
+ end
112
+
113
+ # invoke permission
114
+ def add_lambda_invoke_permission(id:, gateway:)
115
+ arn_path_matcher = gateway[:path].split('/').each_with_object([]) do |fragment, matcher|
116
+ fragment = '*' if fragment.start_with?(':')
117
+ matcher << fragment
118
+ end.join('/')
119
+ @stack.add('LambdaPermission', Humidifier::Lambda::Permission.new(
120
+ action: "lambda:InvokeFunction",
121
+ function_name: Humidifier.fn.get_att([id, 'Arn']),
122
+ principal: "apigateway.amazonaws.com",
123
+ source_arn: Humidifier.fn.sub("arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${#{gateway_id}}/*/#{gateway[:verb]}/#{arn_path_matcher}")
124
+ )
125
+ )
126
+ end
127
+
128
+ # gateway method
129
+ def add_api_gateway_resources(gateway:, lambda:)
130
+
131
+ # example: calculator/algebra/:x/:y/sum -> module name, args, method name
132
+ path = gateway[:path].split('/')
133
+
134
+ # root resource
135
+ root_resource = path.shift
136
+ @stack.add(root_resource.camelize, Humidifier::ApiGateway::Resource.new(
137
+ rest_api_id: Humidifier.ref(AwsStackBuilder.gateway_id),
138
+ parent_id: Humidifier.fn.get_att(["ApiGateway", "RootResourceId"]),
139
+ path_part: root_resource
140
+ )
141
+ )
142
+
143
+ # args and method name are nested resources
144
+ parent_resource = root_resource.camelize
145
+ path.each do |fragment|
146
+ if fragment.start_with?(':')
147
+ fragment = fragment[1..-1]
148
+ dynamic_fragment = "{#{fragment}}"
149
+ end
150
+ @stack.add(parent_resource + fragment.camelize, Humidifier::ApiGateway::Resource.new(
151
+ rest_api_id: Humidifier.ref(AwsStackBuilder.gateway_id),
152
+ parent_id: Humidifier.ref(parent_resource),
153
+ path_part: dynamic_fragment || fragment
154
+ )
155
+ )
156
+ parent_resource = parent_resource + fragment.camelize
157
+ end
158
+
159
+ # attach lambda to last resource
160
+ id = 'EndpointFor' + (gateway[:path].gsub(':', '').gsub('/', '_')).camelize
161
+ @stack.add(id, Humidifier::ApiGateway::Method.new(
162
+ authorization_type: 'NONE',
163
+ http_method: gateway[:verb].to_s.upcase,
164
+ integration: {
165
+ integration_http_method: 'POST',
166
+ type: "AWS_PROXY",
167
+ uri: Humidifier.fn.sub([
168
+ "arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations",
169
+ 'lambdaArn' => Humidifier.fn.get_att([lambda, 'Arn'])
170
+ ])
171
+ },
172
+ rest_api_id: Humidifier.ref(gateway_id),
173
+ resource_id: Humidifier.ref(parent_resource) # last evaluated resource
174
+ )
175
+ )
176
+
177
+ # deployment depends on the method
178
+ @api_gateway_deployment.depends_on << id
179
+ end
180
+
181
+ def add_lambda_iam_role(function_name: nil)
182
+ @stack.add('LambdaRole', Humidifier::IAM::Role.new(
183
+ assume_role_policy_document: {
184
+ 'Version' => "2012-10-17",
185
+ 'Statement' => [
186
+ {
187
+ 'Action' => ["sts:AssumeRole"],
188
+ 'Effect' => "Allow",
189
+ 'Principal' => {
190
+ 'Service' => ["lambda.amazonaws.com"]
191
+ }
192
+ }
193
+ ]
194
+ },
195
+ policies: [
196
+ {
197
+ 'policy_document' => {
198
+ 'Version' => "2012-10-17",
199
+ 'Statement' => [
200
+ {
201
+ 'Action' => [
202
+ "logs:CreateLogStream",
203
+ "logs:PutLogEvents",
204
+ ],
205
+ 'Effect' => "Allow",
206
+ 'Resource' => Humidifier.fn.sub("arn:aws:logs:${AWS::Region}:${AWS::AccountId}:*")
207
+ },
208
+ {
209
+ 'Action' => [
210
+ "logs:CreateLogGroup",
211
+ ],
212
+ 'Effect' => "Allow",
213
+ 'Resource' => "*"
214
+ }
215
+ ]
216
+ },
217
+ 'policy_name' => "cloud-watch-access"
218
+ }
219
+ ]
220
+ )
221
+ )
222
+ end
223
+ end
@@ -0,0 +1,147 @@
1
+ require 'aws-sdk-s3'
2
+ require 'digest'
3
+ require 'bundler'
4
+
5
+ module AwsStackBuilder
6
+ module_function
7
+
8
+ S3Client = Aws::S3::Client.new
9
+
10
+ def upload_lambda_handler
11
+ bucket_name = Humidifier.ref("S3Bucket").reference
12
+ lambda_handler_key = LAMBDA_HANDLER_FILE_NAME + '.rb.zip'
13
+ source = <<~SOURCE
14
+ # see handler AwsApiGatewayEventHandler.call(event: event, context: context) in required file
15
+ require 'lambda/aws_lambda_handler'
16
+ Dir.chdir('/opt/ruby/lib/' + ENV['app_dir'])
17
+ SOURCE
18
+
19
+ existing_handler = S3Client.get_object(
20
+ bucket: bucket,
21
+ key: lambda_handler_key
22
+ ) rescue false # not found
23
+
24
+ existing_source = Zip::InputStream.open(existing_handler.body) do |zip_file|
25
+ zip_file.get_next_entry
26
+ zip_file.read
27
+ end if existing_handler
28
+
29
+ if existing_source != source
30
+ puts '- uploading generic lambda handler'
31
+ source_zip_file = Zip::OutputStream.write_buffer do |zip|
32
+ zip.put_next_entry LAMBDA_HANDLER_FILE_NAME + '.rb'
33
+ zip.print source
34
+ end
35
+ S3Client.put_object(
36
+ bucket: bucket,
37
+ key: lambda_handler_key,
38
+ body: source_zip_file.tap(&:rewind).read
39
+ )
40
+ end
41
+ end
42
+
43
+ def upload_gems_layer
44
+ if !app_path.join('Gemfile').exist?
45
+ puts '- no Gemfile detected'
46
+ return
47
+ end
48
+
49
+ # calculate Gemfile checksum
50
+ checksum_path = app_path.join('.modulator/gemfile_checksum')
51
+ old_checksum = (checksum_path.read rescue nil)
52
+ new_checksum = Digest::MD5.hexdigest(File.read(app_path.join('Gemfile.lock')))
53
+
54
+ zip_file_name = app_dir + '_gems.zip'
55
+ gems_path = app_path.join('.modulator/gems')
56
+ gems_zip_path = gems_path.parent.join(zip_file_name)
57
+
58
+ if old_checksum != new_checksum
59
+ puts '- uploading gems layer'
60
+ checksum_path.write(new_checksum)
61
+
62
+ # bundle gems
63
+ Bundler.with_clean_env do
64
+ Dir.chdir(app_path) do
65
+ `bundle install --path=./.modulator/gems --clean`
66
+ end
67
+ end
68
+ ZipFileGenerator.new(gems_path, gems_zip_path).write
69
+
70
+ # upload zipped file
71
+ gem_layer = S3Client.put_object(
72
+ bucket: bucket,
73
+ key: zip_file_name,
74
+ body: gems_zip_path.read
75
+ )
76
+ # delete zipped file
77
+ FileUtils.remove_dir(gems_path)
78
+ gems_zip_path.delete
79
+ else
80
+ puts '- using existing gems layer'
81
+ gem_layer = S3Client.get_object(bucket: bucket, key: zip_file_name)
82
+ end
83
+
84
+ add_layer(
85
+ name: app_name + 'Gems',
86
+ description: "App gems",
87
+ s3_key: zip_file_name,
88
+ s3_object_version: gem_layer.version_id
89
+ )
90
+ end
91
+
92
+ def upload_app_layer
93
+ wd = Pathname.getwd
94
+ zip_file_name = app_dir + '.zip'
95
+ app_zip_path = wd.join(zip_file_name)
96
+
97
+ # calculate checksum for app folder
98
+ checksum_path = app_path.join('.modulator/app_checksum')
99
+ old_checksum = (checksum_path.read rescue nil)
100
+ new_checksum = checksum(app_path)
101
+
102
+ if old_checksum != new_checksum
103
+ puts '- uploading app layer'
104
+ checksum_path.write(new_checksum)
105
+ ZipFileGenerator.new(app_path, app_zip_path).write
106
+ # upload zipped file
107
+ app_layer = S3Client.put_object(
108
+ bucket: bucket,
109
+ key: zip_file_name,
110
+ body: app_zip_path.read
111
+ )
112
+ # delete zipped file
113
+ app_zip_path.delete
114
+ else
115
+ puts '- using existing app layer'
116
+ app_layer = S3Client.get_object(bucket: bucket, key: zip_file_name)
117
+ end
118
+
119
+ add_layer(
120
+ name: app_name,
121
+ description: "App source. MD5: #{new_checksum}",
122
+ s3_key: zip_file_name,
123
+ s3_object_version: app_layer.version_id
124
+ )
125
+ end
126
+
127
+ # add layer
128
+ def add_layer(name:, description:, s3_key:, s3_object_version:)
129
+ stack.add(name + 'Layer', Humidifier::Lambda::LayerVersion.new(
130
+ compatible_runtimes: [RUBY_VERSION],
131
+ layer_name: name,
132
+ description: description,
133
+ content: {
134
+ s3_bucket: bucket,
135
+ s3_key: s3_key,
136
+ s3_object_version: s3_object_version
137
+ }
138
+ )
139
+ )
140
+ end
141
+
142
+ def checksum(dir)
143
+ files = Dir["#{dir}/**/*"].reject{|f| File.directory?(f)}
144
+ content = files.map{|f| File.read(f)}.join
145
+ Digest::MD5.hexdigest(content)
146
+ end
147
+ end
data/lib/modulator.rb ADDED
@@ -0,0 +1,114 @@
1
+ require 'json'
2
+
3
+ require 'lambda/aws_lambda_handler'
4
+ require 'lambda/aws_stack_builder'
5
+ require 'utils'
6
+
7
+ module Modulator
8
+ module_function
9
+ LAMBDAS = {}
10
+
11
+ def add_lambda(lambda_def, **opts) # opts are for overrides
12
+ if lambda_def.is_a?(Hash)
13
+ add_lambda_from_hash(lambda_def)
14
+ else
15
+ add_lambda_from_module(lambda_def, **opts)
16
+ end
17
+ end
18
+
19
+ def add_lambda_from_hash(hash)
20
+ LAMBDAS[hash[:name]] = {
21
+ name: hash[:name],
22
+ gateway: hash[:gateway],
23
+ module: hash[:module],
24
+ wrapper: hash[:wrapper] || {},
25
+ env: hash[:env] || {},
26
+ settings: hash[:settings] || {}
27
+ }
28
+ end
29
+
30
+ def add_lambda_from_module(mod, **opts)
31
+ mod.singleton_methods.sort.each do |module_method|
32
+ module_name = mod.to_s
33
+ module_names = module_name.split('::').map(&:downcase)
34
+ verb = 'GET'
35
+ path_fragments = module_names.dup
36
+
37
+ # process parameters
38
+ # method(a, b, c = 1, *args, d:, e: 2, **opts)
39
+ # [[:req, :a], [:req, :b], [:opt, :c], [:rest, :args], [:keyreq, :d], [:key, :e], [:keyrest, :opts]]
40
+ mod.method(module_method).parameters.each do |param|
41
+ param_type = param[0]
42
+ param_name = param[1]
43
+
44
+ # collect required params
45
+ path_fragments << ":#{param_name}" if param_type == :req
46
+
47
+ # post if we have optional key param, ie. pet: {}
48
+ verb = 'POST' if param_type == :key
49
+ end
50
+
51
+ # delete is special case based on method name
52
+ verb = 'DELETE' if %w[destroy delete remove implode].include? module_method.to_s
53
+
54
+ # finalize path
55
+ path_fragments << module_method
56
+ path = path_fragments.join('/')
57
+
58
+ add_lambda_from_hash(
59
+ {
60
+ name: "#{module_names.join('-')}-#{module_method}",
61
+ gateway: {
62
+ verb: opts.dig(module_method, :gateway, :verb) || verb,
63
+ path: opts.dig(module_method, :gateway, :path) || path
64
+ },
65
+ module: {
66
+ name: module_name,
67
+ method: module_method.to_s,
68
+ path: module_names.join('/') # file name
69
+ },
70
+ wrapper: opts.dig(module_method, :wrapper) || opts[:wrapper],
71
+ env: opts.dig(module_method, :env),
72
+ settings: opts.dig(module_method, :settings)
73
+ }
74
+ )
75
+ end
76
+ end
77
+
78
+ def set_env(lambda_def)
79
+ # remove wrapper if already set
80
+ %w(name method path).each{|name| ENV.delete('wrapper_' + name)}
81
+ # set env values
82
+ lambda_def[:module].each{|name, value| ENV['module_' + name.to_s] = value.to_s}
83
+ lambda_def[:gateway].each{|name, value| ENV['gateway_' + name.to_s] = value.to_s}
84
+ lambda_def[:wrapper]&.each{|name, value| ENV['wrapper_' + name.to_s] = value.to_s}
85
+ lambda_def[:env]&.each{|name, value| ENV[name.to_s] = value.to_s} # custom values
86
+ end
87
+
88
+ def init_stack(app_name:, bucket:, **stack_opts)
89
+ stack = AwsStackBuilder.init({
90
+ app_name: app_name.camelize,
91
+ bucket: bucket,
92
+ }.merge(stack_opts))
93
+
94
+ # add lambdas to stack
95
+ puts 'Generating endpoints'
96
+ LAMBDAS.each do |name, config|
97
+ puts "- adding #{config.dig(:module, :name)}.#{config.dig(:module, :method)} to #{config.dig(:gateway, :path)}"
98
+ stack.add_lambda_endpoint(
99
+ gateway: config[:gateway],
100
+ mod: config[:module],
101
+ wrapper: config[:wrapper] || {},
102
+ env: config[:env] || {},
103
+ settings: config[:settings] || {}
104
+ )
105
+ end
106
+
107
+ # validate stack
108
+ # puts 'Validating stack'
109
+ # puts '- it is valid' if stack.valid?
110
+
111
+ # return humidifier instance
112
+ stack
113
+ end
114
+ end
data/lib/utils.rb ADDED
@@ -0,0 +1,101 @@
1
+ require 'zip'
2
+
3
+ # NOTE: this file is not required while running your code
4
+ # the patched classes are used only in tests and tools
5
+ class String
6
+ def camelize
7
+ split('_').collect do |word|
8
+ word[0] = word[0].upcase
9
+ word
10
+ end.join
11
+ end
12
+
13
+ def underscore
14
+ gsub(/([A-Z]+)([0-9]|[A-Z]|\z)/){"#{$1.capitalize}#{$2}"}
15
+ .gsub(/(.)([A-Z])/, '\1_\2')
16
+ .downcase
17
+ end
18
+
19
+ def dasherize
20
+ underscore.gsub('_', '-')
21
+ end
22
+ end
23
+
24
+ class Object
25
+ def symbolize_keys
26
+ case self
27
+ when Hash
28
+ hash = {}
29
+ each {|k, v| hash[k.to_sym] = v.symbolize_keys}
30
+ hash
31
+ when Array
32
+ map {|x| x.symbolize_keys}
33
+ else
34
+ self
35
+ end
36
+ end
37
+
38
+
39
+ def stringify_keys
40
+ case self
41
+ when Hash
42
+ hash = {}
43
+ each {|k, v| hash[k.to_s] = v.stringify_keys}
44
+ hash
45
+ when Array
46
+ map {|x| x.stringify_keys}
47
+ else
48
+ self
49
+ end
50
+ end
51
+ end
52
+
53
+ module Utils
54
+ module_function
55
+
56
+ def load_json(path)
57
+ JSON.parse(File.read(path))
58
+ end
59
+ end
60
+
61
+ # NOTE: this code is taken from https://github.com/rubyzip/rubyzip examples
62
+ # Usage:
63
+ # directoryToZip = "/tmp/input"
64
+ # outputFile = "/tmp/out.zip"
65
+ # zf = ZipFileGenerator.new(directoryToZip, outputFile)
66
+ # zf.write()
67
+ class ZipFileGenerator
68
+
69
+ # Initialize with the directory to zip and the location of the output archive.
70
+ def initialize(inputDir, outputFile)
71
+ @inputDir = inputDir
72
+ @outputFile = outputFile
73
+ end
74
+
75
+ # Zip the input directory.
76
+ def write()
77
+ entries = Dir.entries(@inputDir); entries.delete("."); entries.delete("..")
78
+ io = Zip::File.open(@outputFile, Zip::File::CREATE);
79
+
80
+ writeEntries(entries, "", io)
81
+ io.close();
82
+ end
83
+
84
+ # A helper method to make the recursion work.
85
+ private
86
+ def writeEntries(entries, path, io)
87
+
88
+ entries.each { |e|
89
+ zipFilePath = path == "" ? e : File.join(path, e)
90
+ diskFilePath = File.join(@inputDir, zipFilePath)
91
+ # puts "Deflating " + diskFilePath
92
+ if File.directory?(diskFilePath)
93
+ io.mkdir(zipFilePath)
94
+ subdir =Dir.entries(diskFilePath); subdir.delete("."); subdir.delete("..")
95
+ writeEntries(subdir, zipFilePath, io)
96
+ else
97
+ io.get_output_stream(zipFilePath) { |f| f.puts(File.open(diskFilePath, "rb").read())}
98
+ end
99
+ }
100
+ end
101
+ end
data/lib/version.rb ADDED
@@ -0,0 +1,3 @@
1
+ module Modulator
2
+ VERSION = "0.1.1"
3
+ end
data/modulator.gemspec ADDED
@@ -0,0 +1,42 @@
1
+
2
+ lib = File.expand_path("../lib", __FILE__)
3
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
4
+ require "version"
5
+
6
+ Gem::Specification.new do |spec|
7
+ spec.name = "modulator"
8
+ spec.version = Modulator::VERSION
9
+ spec.authors = ["Damir Roso"]
10
+ spec.email = ["damir.roso@webteh.us"]
11
+
12
+ spec.summary = %q{Publish ruby methods as aws lambdas}
13
+ spec.description = %q{Publish ruby methods as aws lambdas}
14
+ spec.homepage = "https://github.com/damir/modulator"
15
+ spec.license = "MIT"
16
+
17
+ # Specify which files should be added to the gem when it is released.
18
+ # The `git ls-files -z` loads the files in the RubyGem that have been added into git.
19
+ spec.files = Dir.chdir(File.expand_path('..', __FILE__)) do
20
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
21
+ end
22
+ spec.bindir = "exe"
23
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
24
+ spec.require_paths = ["lib"]
25
+
26
+ spec.add_development_dependency "bundler", "~> 2.0"
27
+ spec.add_development_dependency "rake", "~> 10.0"
28
+ spec.add_development_dependency "rspec", "~> 3.0"
29
+ spec.add_development_dependency "rack-test", "~> 1.1"
30
+
31
+ # stack builder
32
+ # custom source is not supported by bundler, it is added to gemfile
33
+ # spec.add_dependency "humidifier", github: 'damir/humidifier'
34
+ spec.add_dependency "aws-sdk-s3"
35
+ spec.add_dependency "aws-sdk-cloudformation"
36
+ spec.add_dependency "rubyzip"
37
+
38
+ # local gateway
39
+ spec.add_dependency "puma"
40
+ spec.add_dependency "roda"
41
+ spec.add_dependency "rerun"
42
+ end