aws-sdk-rails 5.0.0 → 5.2.0
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 +4 -4
- data/CHANGELOG.md +12 -0
- data/VERSION +1 -1
- data/lib/aws/rails/middleware/elastic_beanstalk_sqsd/configuration.rb +28 -0
- data/lib/aws/rails/middleware/elastic_beanstalk_sqsd.rb +204 -28
- data/lib/aws-sdk-rails.rb +1 -0
- metadata +4 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 877222ef0a2075585720421300bd93c84591249678bafb02edf1e01962e63051
|
|
4
|
+
data.tar.gz: caed28f5f0bb7f604b5b2aeee08e59d5b8e6ad3612b4f9e8189b0a22e448ceeb
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: fc56e477c15a69e1773f305806118ba2aa7357840f68edbe6b491c569cdcb98ff417ad45abe38f620e5bc6b3c0010e00a1b0bedf987006f8ead442ab2dea4597
|
|
7
|
+
data.tar.gz: 33ede8ffc209b921330121a0f7642d1af4a97ce299dcc7613899f0b0322ec0e4e99b1ed098e601c08402496666a890d030502f9b41fe540c199d10f7bf6fa325
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
5.2.0 (2026-08-11)
|
|
2
|
+
------------------
|
|
3
|
+
|
|
4
|
+
* Issue - The `ElasticBeanstalkSQSD` middleware's Docker host check now only consults `remote_addr`, the raw TCP peer address, instead of also accepting `remote_ip`. `remote_ip` is derived from the client-supplied `X-Forwarded-For` header whenever the peer address is itself private, so a request from any private address could name the Docker gateway and be treated as local. Loopback peer addresses are accepted by the check, so requests proxied over loopback are unaffected.
|
|
5
|
+
* Issue - Only classes inheriting from `ActiveJob::Base` are executed by the `ElasticBeanstalkSQSD` middleware, preventing arbitrary classes named in an SQS message from being instantiated and run. Job class and periodic task names are validated as constant paths and resolved without searching the namespace's ancestors, so a request cannot name a constant outside the intended namespace.
|
|
6
|
+
* Feature - Adds a new configuration object for elastic beanstalk sqsd middleware, with an optional `job_class_allowlist` configuration to `ElasticBeanstalkSQSD` middleware to restrict which job classes can be dispatched. When set, the allowlist is checked before the class name is resolved, so an excluded class is never loaded.
|
|
7
|
+
|
|
8
|
+
5.1.0 (2024-12-05)
|
|
9
|
+
------------------
|
|
10
|
+
|
|
11
|
+
* Feature - Support async job processing in Elastic Beanstalk middleware. (#167)
|
|
12
|
+
|
|
1
13
|
5.0.0 (2024-11-21)
|
|
2
14
|
------------------
|
|
3
15
|
|
data/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.
|
|
1
|
+
5.2.0
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Aws
|
|
4
|
+
module Rails
|
|
5
|
+
module Middleware
|
|
6
|
+
class ElasticBeanstalkSQSD
|
|
7
|
+
# Configuration for the {ElasticBeanstalkSQSD} middleware.
|
|
8
|
+
#
|
|
9
|
+
# Use {ElasticBeanstalkSQSD.config} to access the singleton config
|
|
10
|
+
# instance and {ElasticBeanstalkSQSD.configure} to configure in code:
|
|
11
|
+
#
|
|
12
|
+
# Aws::Rails::Middleware::ElasticBeanstalkSQSD.configure do |config|
|
|
13
|
+
# config.job_class_allowlist = [SendReceiptJob, ProcessOrderJob]
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
class Configuration
|
|
17
|
+
# @return [Array<Class, String>, nil] Optional list of job classes
|
|
18
|
+
# permitted to be executed. When set, only classes in this list
|
|
19
|
+
# will be dispatched. When nil, any class inheriting from
|
|
20
|
+
# ActiveJob::Base is allowed. Entries may be given as Class objects
|
|
21
|
+
# or Strings; matching is by class name, so an allowlisted class is
|
|
22
|
+
# still recognized after a Zeitwerk reload replaces the class object.
|
|
23
|
+
attr_accessor :job_class_allowlist
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -4,10 +4,49 @@ module Aws
|
|
|
4
4
|
module Rails
|
|
5
5
|
module Middleware
|
|
6
6
|
# Middleware to handle requests from the SQS Daemon present on Elastic Beanstalk worker environments.
|
|
7
|
-
|
|
7
|
+
#
|
|
8
|
+
# See {Configuration} for the available options and {configure} for
|
|
9
|
+
# setting them in code.
|
|
10
|
+
class ElasticBeanstalkSQSD # rubocop:disable Metrics/ClassLength
|
|
11
|
+
# Raised when a job message names a class that cannot be executed -
|
|
12
|
+
# the name is not a well-formed constant path, it is not in the
|
|
13
|
+
# configured job_class_allowlist, or it does not inherit from
|
|
14
|
+
# ActiveJob::Base.
|
|
15
|
+
class InvalidJobClassError < StandardError; end
|
|
16
|
+
|
|
17
|
+
# A job class name is an (optionally namespaced) constant path and
|
|
18
|
+
# nothing else. Names that cannot match this can never name a job
|
|
19
|
+
# class, so rejecting them keeps them out of the constant lookup.
|
|
20
|
+
JOB_CLASS_NAME_PATTERN = /\A[A-Z]\w*(::[A-Z]\w*)*\z/.freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# Yields the middleware configuration for customization.
|
|
24
|
+
#
|
|
25
|
+
# @example Restrict job dispatch to specific classes
|
|
26
|
+
# Aws::Rails::Middleware::ElasticBeanstalkSQSD.configure do |config|
|
|
27
|
+
# config.job_class_allowlist = [SendReceiptJob, ProcessOrderJob]
|
|
28
|
+
# end
|
|
29
|
+
#
|
|
30
|
+
# @yieldparam [Configuration] config
|
|
31
|
+
# @return [Configuration]
|
|
32
|
+
def configure
|
|
33
|
+
yield(config) if block_given?
|
|
34
|
+
config
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
# @return [Configuration] the current middleware configuration.
|
|
38
|
+
def config
|
|
39
|
+
@config ||= Configuration.new
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
8
43
|
def initialize(app)
|
|
9
44
|
@app = app
|
|
10
45
|
@logger = ::Rails.logger
|
|
46
|
+
|
|
47
|
+
return unless ENV['AWS_PROCESS_BEANSTALK_WORKER_JOBS_ASYNC']
|
|
48
|
+
|
|
49
|
+
@executor = init_executor
|
|
11
50
|
end
|
|
12
51
|
|
|
13
52
|
def call(env)
|
|
@@ -18,55 +57,129 @@ module Aws
|
|
|
18
57
|
|
|
19
58
|
@logger.debug('aws-sdk-rails middleware detected call from Elastic Beanstalk SQS Daemon.')
|
|
20
59
|
|
|
21
|
-
#
|
|
60
|
+
# Best-effort source check. On Docker-based EB platforms this is unreliable
|
|
61
|
+
# because all traffic arrives via the docker bridge. Use job_class_allowlist
|
|
62
|
+
# and network-level controls (security groups) as the primary safeguards.
|
|
22
63
|
unless request.local? || sent_from_docker_host?(request)
|
|
23
64
|
@logger.warn('SQSD request detected from untrusted address; returning 403 forbidden.')
|
|
24
65
|
return forbidden_response
|
|
25
66
|
end
|
|
26
67
|
|
|
27
68
|
# Execute job or periodic task based on HTTP request context
|
|
28
|
-
|
|
69
|
+
execute(request)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def shutdown(timeout = nil)
|
|
73
|
+
return unless @executor
|
|
74
|
+
|
|
75
|
+
@logger.info("Shutting down SQS EBS background job executor. Timeout: #{timeout}")
|
|
76
|
+
@executor.shutdown
|
|
77
|
+
clean_shutdown = @executor.wait_for_termination(timeout)
|
|
78
|
+
@logger.info("SQS EBS background executor shutdown complete. Clean: #{clean_shutdown}")
|
|
29
79
|
end
|
|
30
80
|
|
|
31
81
|
private
|
|
32
82
|
|
|
83
|
+
def init_executor
|
|
84
|
+
threads = Integer(ENV.fetch('AWS_PROCESS_BEANSTALK_WORKER_THREADS',
|
|
85
|
+
Concurrent.available_processor_count || Concurrent.processor_count))
|
|
86
|
+
options = {
|
|
87
|
+
max_threads: threads,
|
|
88
|
+
max_queue: 1,
|
|
89
|
+
auto_terminate: false, # register our own at_exit to gracefully shutdown
|
|
90
|
+
fallback_policy: :abort # Concurrent::RejectedExecutionError must be handled
|
|
91
|
+
}
|
|
92
|
+
at_exit { shutdown }
|
|
93
|
+
|
|
94
|
+
Concurrent::ThreadPoolExecutor.new(options)
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
def execute(request)
|
|
98
|
+
if periodic_task?(request)
|
|
99
|
+
execute_periodic_task(request)
|
|
100
|
+
else
|
|
101
|
+
execute_job(request)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
33
105
|
def execute_job(request)
|
|
106
|
+
if @executor
|
|
107
|
+
_execute_job_background(request)
|
|
108
|
+
else
|
|
109
|
+
_execute_job_now(request)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Execute a job in the current thread
|
|
114
|
+
def _execute_job_now(request)
|
|
34
115
|
# Jobs queued from the SQS adapter contain the JSON message in the request body.
|
|
35
116
|
job = ::ActiveSupport::JSON.decode(request.body.string)
|
|
36
117
|
job_name = job['job_class']
|
|
118
|
+
# Scope the rescue to resolution only. A NameError raised from inside
|
|
119
|
+
# the job's own #perform must not be mislabeled as a class-resolution
|
|
120
|
+
# failure, so ::ActiveJob::Base.execute runs outside the begin/rescue.
|
|
121
|
+
begin
|
|
122
|
+
resolve_job_class(job_name)
|
|
123
|
+
rescue NameError, InvalidJobClassError => e
|
|
124
|
+
return unresolved_job_class_response(job_name, e)
|
|
125
|
+
end
|
|
37
126
|
@logger.debug("Executing job: #{job_name}")
|
|
38
|
-
|
|
127
|
+
::ActiveJob::Base.execute(job)
|
|
39
128
|
[200, { 'Content-Type' => 'text/plain' }, ["Successfully ran job #{job_name}."]]
|
|
40
|
-
rescue NameError
|
|
41
|
-
internal_error_response
|
|
42
129
|
end
|
|
43
130
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
131
|
+
# Execute a job using the thread pool executor
|
|
132
|
+
def _execute_job_background(request)
|
|
133
|
+
job_data = ::ActiveSupport::JSON.decode(request.body.string)
|
|
134
|
+
job_name = job_data['job_class']
|
|
135
|
+
resolve_job_class(job_name)
|
|
136
|
+
@logger.debug("Queuing background job: #{job_name}")
|
|
137
|
+
@executor.post(job_data) do |job|
|
|
138
|
+
::ActiveJob::Base.execute(job)
|
|
139
|
+
end
|
|
140
|
+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully queued job #{job_name}"]]
|
|
141
|
+
rescue Concurrent::RejectedExecutionError
|
|
142
|
+
msg = 'No capacity to execute job.'
|
|
143
|
+
@logger.info(msg)
|
|
144
|
+
[429, { 'Content-Type' => 'text/plain' }, [msg]]
|
|
145
|
+
rescue NameError, InvalidJobClassError => e
|
|
146
|
+
unresolved_job_class_response(job_name, e)
|
|
50
147
|
end
|
|
51
148
|
|
|
52
149
|
def execute_periodic_task(request)
|
|
53
150
|
# The beanstalk worker SQS Daemon will add the 'X-Aws-Sqsd-Taskname' for periodic tasks set in cron.yaml.
|
|
54
151
|
job_name = request.headers['X-Aws-Sqsd-Taskname']
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
152
|
+
# Resolve once and reuse the class, rather than resolving the name a
|
|
153
|
+
# second time with another constantize (which also left a check-then-
|
|
154
|
+
# act gap between what was validated and what ran). Scope the rescue
|
|
155
|
+
# to resolution only, so a NameError from inside the task's own
|
|
156
|
+
# #perform is not mislabeled as a cron-name resolution failure.
|
|
157
|
+
begin
|
|
158
|
+
job = resolve_job_class(job_name).new
|
|
159
|
+
rescue NameError, InvalidJobClassError => e
|
|
160
|
+
return unresolved_periodic_task_response(job_name, e)
|
|
161
|
+
end
|
|
162
|
+
if @executor
|
|
163
|
+
_execute_periodic_task_background(job)
|
|
164
|
+
else
|
|
165
|
+
_execute_periodic_task_now(job)
|
|
166
|
+
end
|
|
60
167
|
end
|
|
61
168
|
|
|
62
|
-
def
|
|
63
|
-
|
|
169
|
+
def _execute_periodic_task_now(job)
|
|
170
|
+
@logger.debug("Executing periodic task: #{job.class}")
|
|
64
171
|
job.perform_now
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
172
|
+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully ran periodic task #{job.class}."]]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def _execute_periodic_task_background(job)
|
|
176
|
+
@logger.debug("Queuing bakground periodic task: #{job.class}")
|
|
177
|
+
@executor.post(job, &:perform_now)
|
|
178
|
+
[200, { 'Content-Type' => 'text/plain' }, ["Successfully queued periodic task #{job.class}"]]
|
|
179
|
+
rescue Concurrent::RejectedExecutionError
|
|
180
|
+
msg = 'No capacity to execute periodic task.'
|
|
181
|
+
@logger.info(msg)
|
|
182
|
+
[429, { 'Content-Type' => 'text/plain' }, [msg]]
|
|
70
183
|
end
|
|
71
184
|
|
|
72
185
|
def internal_error_response
|
|
@@ -74,6 +187,19 @@ module Aws
|
|
|
74
187
|
[500, { 'Content-Type' => 'text/plain' }, [message]]
|
|
75
188
|
end
|
|
76
189
|
|
|
190
|
+
def unresolved_job_class_response(job_name, error)
|
|
191
|
+
@logger.error("Job #{job_name} could not resolve to a class that inherits from Active Job.")
|
|
192
|
+
@logger.error("Error: #{error}")
|
|
193
|
+
internal_error_response
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def unresolved_periodic_task_response(job_name, error)
|
|
197
|
+
@logger.error("Periodic task #{job_name} could not resolve to an Active Job class " \
|
|
198
|
+
'- check the cron name spelling and set the path as / in cron.yaml.')
|
|
199
|
+
@logger.error("Error: #{error}.")
|
|
200
|
+
internal_error_response
|
|
201
|
+
end
|
|
202
|
+
|
|
77
203
|
def forbidden_response
|
|
78
204
|
message = 'Request with aws-sqsd user agent was made from untrusted address.'
|
|
79
205
|
[403, { 'Content-Type' => 'text/plain' }, [message]]
|
|
@@ -92,6 +218,48 @@ module Aws
|
|
|
92
218
|
request.headers['X-Aws-Sqsd-Taskname'].present? && request.fullpath == '/'
|
|
93
219
|
end
|
|
94
220
|
|
|
221
|
+
# Resolves +name+ to a job class, raising InvalidJobClassError if it is
|
|
222
|
+
# not a well-formed constant path, is not in the configured allowlist,
|
|
223
|
+
# or does not name a class inheriting from ActiveJob::Base. Returns the
|
|
224
|
+
# resolved class so callers can reuse it without resolving the name a
|
|
225
|
+
# second time.
|
|
226
|
+
#
|
|
227
|
+
# Checks are ordered so that the least trusting one runs first. Merely
|
|
228
|
+
# resolving a constant is not inert: under Zeitwerk it triggers
|
|
229
|
+
# autoloading, which runs the body of whichever file defines it. So the
|
|
230
|
+
# name is matched against the allowlist as a String, before any lookup,
|
|
231
|
+
# and an excluded name never reaches the constant resolver at all.
|
|
232
|
+
def resolve_job_class(name)
|
|
233
|
+
name = name.to_s
|
|
234
|
+
raise InvalidJobClassError, "#{name.inspect} is not a valid job class name" unless
|
|
235
|
+
JOB_CLASS_NAME_PATTERN.match?(name)
|
|
236
|
+
|
|
237
|
+
allowlist = self.class.config.job_class_allowlist
|
|
238
|
+
# Match by class name, not object identity: in development Zeitwerk
|
|
239
|
+
# reloads produce a new class object with the same name, so an
|
|
240
|
+
# allowlist of Class objects would reject every job after a reload.
|
|
241
|
+
if allowlist && !allowlist.map(&:to_s).include?(name)
|
|
242
|
+
raise InvalidJobClassError, "#{name} is not in the configured job_class_allowlist"
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
klass = constantize_job_class(name)
|
|
246
|
+
unless klass.is_a?(Class) && klass < ::ActiveJob::Base
|
|
247
|
+
raise InvalidJobClassError, "#{name} is not a valid job class (must inherit from ActiveJob::Base)"
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
klass
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Resolves an already-validated name. +inherit+ is false so that each
|
|
254
|
+
# segment must be defined directly on the preceding one: a name such as
|
|
255
|
+
# 'SomeJob::Foo' cannot resolve Foo from SomeJob's ancestors when
|
|
256
|
+
# SomeJob itself does not define it.
|
|
257
|
+
def constantize_job_class(name)
|
|
258
|
+
# CodeQL [rb/code-injection] Name is validated against JOB_CLASS_NAME_PATTERN
|
|
259
|
+
# and the allowlist above, and the result is checked for ActiveJob::Base ancestry below.
|
|
260
|
+
Object.const_get(name, false)
|
|
261
|
+
end
|
|
262
|
+
|
|
95
263
|
def sent_from_docker_host?(request)
|
|
96
264
|
app_runs_in_docker_container? && ip_originates_from_docker_host?(request)
|
|
97
265
|
end
|
|
@@ -108,16 +276,24 @@ module Aws
|
|
|
108
276
|
File.exist?('/proc/self/mountinfo') && File.read('/proc/self/mountinfo') =~ %r{/docker/containers/}
|
|
109
277
|
end
|
|
110
278
|
|
|
279
|
+
# Only consults remote_addr, the raw TCP peer address. remote_ip is
|
|
280
|
+
# derived from X-Forwarded-For whenever the peer is itself private, and
|
|
281
|
+
# the docker gateway range (172.16.0.0/12) is one Rack treats as a
|
|
282
|
+
# trusted proxy to see through - so a request from any private peer can
|
|
283
|
+
# set remote_ip to the gateway with a header alone. Loopback is accepted
|
|
284
|
+
# here because such a request is genuinely local; request.local? rejects
|
|
285
|
+
# it only on the forgeable half of its check.
|
|
111
286
|
def ip_originates_from_docker_host?(request)
|
|
112
|
-
|
|
113
|
-
|
|
287
|
+
remote_addr = request.remote_addr
|
|
288
|
+
default_docker_ips.include?(remote_addr) ||
|
|
289
|
+
::ActionDispatch::Request::LOCALHOST.match?(remote_addr)
|
|
114
290
|
end
|
|
115
291
|
|
|
116
292
|
def default_docker_ips
|
|
117
293
|
@default_docker_ips ||= build_default_docker_ips
|
|
118
294
|
end
|
|
119
295
|
|
|
120
|
-
# rubocop:disable Metrics/AbcSize
|
|
296
|
+
# rubocop:disable Metrics/AbcSize, Style/FileOpen
|
|
121
297
|
def build_default_docker_ips
|
|
122
298
|
default_gw_ips = ['172.17.0.1']
|
|
123
299
|
|
|
@@ -134,7 +310,7 @@ module Aws
|
|
|
134
310
|
|
|
135
311
|
default_gw_ips
|
|
136
312
|
end
|
|
137
|
-
# rubocop:enable Metrics/AbcSize
|
|
313
|
+
# rubocop:enable Metrics/AbcSize, Style/FileOpen
|
|
138
314
|
end
|
|
139
315
|
end
|
|
140
316
|
end
|
data/lib/aws-sdk-rails.rb
CHANGED
metadata
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: aws-sdk-rails
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 5.
|
|
4
|
+
version: 5.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Amazon Web Services
|
|
8
|
-
autorequire:
|
|
9
8
|
bindir: bin
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
12
11
|
dependencies:
|
|
13
12
|
- !ruby/object:Gem::Dependency
|
|
14
13
|
name: aws-sdk-core
|
|
@@ -50,13 +49,13 @@ files:
|
|
|
50
49
|
- VERSION
|
|
51
50
|
- lib/aws-sdk-rails.rb
|
|
52
51
|
- lib/aws/rails/middleware/elastic_beanstalk_sqsd.rb
|
|
52
|
+
- lib/aws/rails/middleware/elastic_beanstalk_sqsd/configuration.rb
|
|
53
53
|
- lib/aws/rails/notifications.rb
|
|
54
54
|
- lib/aws/rails/railtie.rb
|
|
55
55
|
homepage: https://github.com/aws/aws-sdk-rails
|
|
56
56
|
licenses:
|
|
57
57
|
- Apache-2.0
|
|
58
58
|
metadata: {}
|
|
59
|
-
post_install_message:
|
|
60
59
|
rdoc_options: []
|
|
61
60
|
require_paths:
|
|
62
61
|
- lib
|
|
@@ -71,8 +70,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
71
70
|
- !ruby/object:Gem::Version
|
|
72
71
|
version: '0'
|
|
73
72
|
requirements: []
|
|
74
|
-
rubygems_version:
|
|
75
|
-
signing_key:
|
|
73
|
+
rubygems_version: 4.0.16
|
|
76
74
|
specification_version: 4
|
|
77
75
|
summary: AWS SDK for Ruby on Rails Railtie
|
|
78
76
|
test_files: []
|