fluent-plugin-azurestorage 0.0.8 → 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/README.md +42 -0
- data/VERSION +1 -1
- data/fluent-plugin-azurestorage.gemspec +2 -2
- data/lib/fluent/plugin/out_azurestorage.rb +64 -27
- data/test/test_out_azurestorage.rb +80 -62
- metadata +7 -7
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA1:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 19fc61140e33ed5db2e6f41736d8a41515fd2eab
|
4
|
+
data.tar.gz: c83005cb236f4b19a8c547945930e4783bd02366
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: ca632bb038f91cf996ff63bb171bdda0e67baee3c6911513ef4f884862cb2fd0ea17161273518134eb952eb406b2561ae1b07968466e1cb190b112c7a7bbd65a
|
7
|
+
data.tar.gz: 12c019aa3ef994cd7089f5fe419e5ffd301226429054a85f18b94e3abf24b157c2427854755f772fa27fd726d70632fdbf21178c61fce7950253fb20bd36309f
|
data/.gitignore
CHANGED
data/README.md
CHANGED
@@ -16,6 +16,48 @@ $ gem install fluent-plugin-azurestorage
|
|
16
16
|
|
17
17
|
## Configuration
|
18
18
|
|
19
|
+
### v0.14 style
|
20
|
+
|
21
|
+
```
|
22
|
+
<match pattern>
|
23
|
+
type azurestorage
|
24
|
+
|
25
|
+
azure_storage_account <your azure storage account>
|
26
|
+
azure_storage_access_key <your azure storage access key>
|
27
|
+
azure_container <your azure storage container>
|
28
|
+
azure_storage_type blob
|
29
|
+
store_as gzip
|
30
|
+
auto_create_container true
|
31
|
+
path logs/
|
32
|
+
azure_object_key_format %{path}%{time_slice}_%{index}.%{file_extension}
|
33
|
+
time_slice_format %Y%m%d-%H
|
34
|
+
# if you want to use ${tag} or %Y/%m/%d/ like syntax in path / s3_object_key_format,
|
35
|
+
# need to specify tag for ${tag} and time for %Y/%m/%d in <buffer> argument.
|
36
|
+
<buffer tag,time>
|
37
|
+
@type file
|
38
|
+
path /var/log/fluent/azurestorage
|
39
|
+
timekey 3600 # 1 hour partition
|
40
|
+
timekey_wait 10m
|
41
|
+
timekey_use_utc true # use utc
|
42
|
+
</buffer>
|
43
|
+
</match>
|
44
|
+
```
|
45
|
+
|
46
|
+
For `<buffer>`, you can use any record field in `path` / `azure_object_key_format`.
|
47
|
+
|
48
|
+
```
|
49
|
+
path logs/${tag}/${foo}
|
50
|
+
<buffer tag,foo>
|
51
|
+
# parameters...
|
52
|
+
</buffer>
|
53
|
+
```
|
54
|
+
|
55
|
+
See official article for more detail: Buffer section configurations
|
56
|
+
|
57
|
+
Note that this configuration doesn't work with fluentd v0.12.
|
58
|
+
|
59
|
+
### v0.12 style
|
60
|
+
|
19
61
|
```
|
20
62
|
<match pattern>
|
21
63
|
type azurestorage
|
data/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0
|
1
|
+
0.1.0
|
@@ -17,9 +17,9 @@ Gem::Specification.new do |gem|
|
|
17
17
|
gem.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
|
18
18
|
gem.require_paths = ['lib']
|
19
19
|
|
20
|
-
gem.add_dependency "fluentd", [">= 0.
|
20
|
+
gem.add_dependency "fluentd", [">= 0.14.0", "< 2"]
|
21
21
|
gem.add_dependency "azure", [">= 0.7.1", "<= 0.7.7"]
|
22
|
-
gem.add_dependency "
|
22
|
+
gem.add_dependency "uuidtools", ">= 2.1.5"
|
23
23
|
gem.add_development_dependency "rake", ">= 0.9.2"
|
24
24
|
gem.add_development_dependency "test-unit", ">= 3.0.8"
|
25
25
|
gem.add_development_dependency "test-unit-rr", ">= 1.0.3"
|
@@ -1,16 +1,18 @@
|
|
1
|
-
|
2
|
-
|
3
|
-
|
4
|
-
|
1
|
+
require 'azure'
|
2
|
+
require 'fluent/plugin/upload_service'
|
3
|
+
require 'zlib'
|
4
|
+
require 'time'
|
5
|
+
require 'tempfile'
|
6
|
+
require 'fluent/plugin/output'
|
7
|
+
|
8
|
+
module Fluent::Plugin
|
9
|
+
class AzureStorageOutput < Fluent::Plugin::Output
|
5
10
|
Fluent::Plugin.register_output('azurestorage', self)
|
6
11
|
|
12
|
+
helpers :compat_parameters, :formatter, :inject
|
13
|
+
|
7
14
|
def initialize
|
8
15
|
super
|
9
|
-
require 'azure'
|
10
|
-
require 'fluent/plugin/upload_service'
|
11
|
-
require 'zlib'
|
12
|
-
require 'time'
|
13
|
-
require 'tempfile'
|
14
16
|
|
15
17
|
@compressor = nil
|
16
18
|
end
|
@@ -26,27 +28,32 @@ module Fluent
|
|
26
28
|
config_param :format, :string, :default => "out_file"
|
27
29
|
config_param :command_parameter, :string, :default => nil
|
28
30
|
|
29
|
-
|
31
|
+
DEFAULT_FORMAT_TYPE = "out_file"
|
30
32
|
|
31
|
-
|
33
|
+
config_section :format do
|
34
|
+
config_set_default :@type, DEFAULT_FORMAT_TYPE
|
35
|
+
end
|
32
36
|
|
33
|
-
|
34
|
-
[
|
37
|
+
config_section :buffer do
|
38
|
+
config_set_default :chunk_keys, ['time']
|
39
|
+
config_set_default :timekey, (60 * 60 * 24)
|
35
40
|
end
|
36
41
|
|
42
|
+
attr_reader :bs
|
43
|
+
|
37
44
|
def configure(conf)
|
45
|
+
compat_parameters_convert(conf, :buffer, :formatter, :inject)
|
38
46
|
super
|
39
47
|
|
40
48
|
begin
|
41
49
|
@compressor = COMPRESSOR_REGISTRY.lookup(@store_as).new(:buffer_type => @buffer_type, :log => log)
|
42
50
|
rescue => e
|
43
|
-
|
51
|
+
log.warn "#{@store_as} not found. Use 'text' instead"
|
44
52
|
@compressor = TextCompressor.new
|
45
53
|
end
|
46
54
|
@compressor.configure(conf)
|
47
55
|
|
48
|
-
@formatter =
|
49
|
-
@formatter.configure(conf)
|
56
|
+
@formatter = formatter_create
|
50
57
|
|
51
58
|
if @localtime
|
52
59
|
@path_slicer = Proc.new {|path|
|
@@ -70,6 +77,13 @@ module Fluent
|
|
70
77
|
else
|
71
78
|
'blob'
|
72
79
|
end
|
80
|
+
# For backward compatibility
|
81
|
+
# TODO: Remove time_slice_format when end of support compat_parameters
|
82
|
+
@configured_time_slice_format = conf['time_slice_format']
|
83
|
+
end
|
84
|
+
|
85
|
+
def multi_workers_ready?
|
86
|
+
true
|
73
87
|
end
|
74
88
|
|
75
89
|
def start
|
@@ -88,25 +102,32 @@ module Fluent
|
|
88
102
|
end
|
89
103
|
|
90
104
|
def format(tag, time, record)
|
91
|
-
|
105
|
+
r = inject_values_to_record(tag, time, record)
|
106
|
+
@formatter.format(tag, time, r)
|
92
107
|
end
|
93
108
|
|
94
109
|
def write(chunk)
|
95
110
|
i = 0
|
111
|
+
metadata = chunk.metadata
|
96
112
|
previous_path = nil
|
113
|
+
time_slice_format = @configured_time_slice_format || timekey_to_timeformat(@buffer_config['timekey'])
|
114
|
+
time_slice = if metadata.timekey.nil?
|
115
|
+
''.freeze
|
116
|
+
else
|
117
|
+
Time.at(metadata.timekey).utc.strftime(time_slice_format)
|
118
|
+
end
|
97
119
|
|
98
120
|
begin
|
99
121
|
path = @path_slicer.call(@path)
|
100
122
|
values_for_object_key = {
|
101
|
-
"path" => path,
|
102
|
-
"time_slice" =>
|
103
|
-
"file_extension" => @compressor.ext,
|
104
|
-
"index" => i,
|
105
|
-
"uuid_flush" => uuid_random
|
106
|
-
}
|
107
|
-
storage_path = @azure_object_key_format.gsub(%r(%{[^}]+})) { |expr|
|
108
|
-
values_for_object_key[expr[2...expr.size-1]]
|
123
|
+
"%{path}" => path,
|
124
|
+
"%{time_slice}" => time_slice,
|
125
|
+
"%{file_extension}" => @compressor.ext,
|
126
|
+
"%{index}" => i,
|
127
|
+
"%{uuid_flush}" => uuid_random
|
109
128
|
}
|
129
|
+
storage_path = @azure_object_key_format.gsub(%r(%{[^}]+}), values_for_object_key)
|
130
|
+
storage_path = extract_placeholders(storage_path, metadata)
|
110
131
|
if (i > 0) && (storage_path == previous_path)
|
111
132
|
raise "duplicated path is generated. use %{index} in azure_object_key_format: path = #{storage_path}"
|
112
133
|
end
|
@@ -140,8 +161,24 @@ module Fluent
|
|
140
161
|
end
|
141
162
|
end
|
142
163
|
|
164
|
+
def uuid_random
|
165
|
+
require 'uuidtools'
|
166
|
+
::UUIDTools::UUID.random_create.to_s
|
167
|
+
end
|
168
|
+
|
169
|
+
# This is stolen from Fluentd
|
170
|
+
def timekey_to_timeformat(timekey)
|
171
|
+
case timekey
|
172
|
+
when nil then ''
|
173
|
+
when 0...60 then '%Y%m%d%H%M%S' # 60 exclusive
|
174
|
+
when 60...3600 then '%Y%m%d%H%M'
|
175
|
+
when 3600...86400 then '%Y%m%d%H'
|
176
|
+
else '%Y%m%d'
|
177
|
+
end
|
178
|
+
end
|
179
|
+
|
143
180
|
class Compressor
|
144
|
-
include Configurable
|
181
|
+
include Fluent::Configurable
|
145
182
|
|
146
183
|
def initialize(opts = {})
|
147
184
|
super()
|
@@ -220,7 +257,7 @@ module Fluent
|
|
220
257
|
end
|
221
258
|
end
|
222
259
|
|
223
|
-
COMPRESSOR_REGISTRY = Registry.new(:azurestorage_compressor_type, 'fluent/plugin/azurestorage_compressor_')
|
260
|
+
COMPRESSOR_REGISTRY = Fluent::Registry.new(:azurestorage_compressor_type, 'fluent/plugin/azurestorage_compressor_')
|
224
261
|
{
|
225
262
|
'gzip' => GzipCompressor,
|
226
263
|
'json' => JsonCompressor,
|
@@ -1,10 +1,14 @@
|
|
1
1
|
require 'fluent/test'
|
2
|
+
require 'fluent/test/driver/output'
|
3
|
+
require 'fluent/test/helpers'
|
2
4
|
require 'fluent/plugin/out_azurestorage'
|
3
5
|
|
4
6
|
require 'test/unit/rr'
|
5
7
|
require 'zlib'
|
6
8
|
require 'fileutils'
|
7
9
|
|
10
|
+
include Fluent::Test::Helpers
|
11
|
+
|
8
12
|
class AzureStorageOutputTest < Test::Unit::TestCase
|
9
13
|
def setup
|
10
14
|
require 'azure'
|
@@ -21,9 +25,16 @@ class AzureStorageOutputTest < Test::Unit::TestCase
|
|
21
25
|
]
|
22
26
|
|
23
27
|
def create_driver(conf = CONFIG)
|
24
|
-
Fluent::Test::
|
28
|
+
Fluent::Test::Driver::Output.new(Fluent::Plugin::AzureStorageOutput) do
|
29
|
+
# for testing.
|
30
|
+
def contents
|
31
|
+
@emit_streams
|
32
|
+
end
|
33
|
+
|
25
34
|
def write(chunk)
|
26
|
-
|
35
|
+
@emit_streams = []
|
36
|
+
event = chunk.read
|
37
|
+
@emit_streams << event
|
27
38
|
end
|
28
39
|
|
29
40
|
private
|
@@ -64,11 +75,9 @@ class AzureStorageOutputTest < Test::Unit::TestCase
|
|
64
75
|
conf = CONFIG.clone
|
65
76
|
conf << "\nstore_as lzo\n"
|
66
77
|
d = create_driver(conf)
|
67
|
-
|
68
|
-
assert_equal '
|
69
|
-
|
70
|
-
# TODO: replace code with disable lzop command
|
71
|
-
assert(e.is_a?(Fluent::ConfigError))
|
78
|
+
# Fallback to text/plain.
|
79
|
+
assert_equal 'txt', d.instance.instance_variable_get(:@compressor).ext
|
80
|
+
assert_equal 'text/plain', d.instance.instance_variable_get(:@compressor).content_type
|
72
81
|
end
|
73
82
|
|
74
83
|
def test_path_slicing
|
@@ -93,109 +102,118 @@ class AzureStorageOutputTest < Test::Unit::TestCase
|
|
93
102
|
def test_format
|
94
103
|
d = create_driver
|
95
104
|
|
96
|
-
time =
|
97
|
-
d.
|
98
|
-
|
99
|
-
|
100
|
-
|
101
|
-
d.
|
105
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
106
|
+
d.run(default_tag: "test") do
|
107
|
+
d.feed(time, {"a"=>1})
|
108
|
+
d.feed(time, {"a"=>2})
|
109
|
+
end
|
110
|
+
formatted = d.formatted
|
102
111
|
|
103
|
-
|
112
|
+
assert_equal %[2011-01-02T13:14:15Z\ttest\t{"a":1}\n], formatted[0]
|
113
|
+
assert_equal %[2011-01-02T13:14:15Z\ttest\t{"a":2}\n], formatted[1]
|
104
114
|
end
|
105
115
|
|
106
116
|
def test_format_included_tag_and_time
|
107
117
|
config = [CONFIG, 'include_tag_key true', 'include_time_key true'].join("\n")
|
108
118
|
d = create_driver(config)
|
109
119
|
|
110
|
-
time =
|
111
|
-
d.
|
112
|
-
|
120
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
121
|
+
d.run(default_tag: "test") do
|
122
|
+
d.feed(time, {"a"=>1})
|
123
|
+
d.feed(time, {"a"=>2})
|
124
|
+
end
|
125
|
+
formatted = d.formatted
|
113
126
|
|
114
|
-
|
115
|
-
|
116
|
-
|
117
|
-
d.run
|
127
|
+
assert_equal %[2011-01-02T13:14:15Z\ttest\t{"a":1,"tag":"test","time":"2011-01-02T13:14:15Z"}\n], formatted[0]
|
128
|
+
assert_equal %[2011-01-02T13:14:15Z\ttest\t{"a":2,"tag":"test","time":"2011-01-02T13:14:15Z"}\n], d.formatted[1]
|
118
129
|
end
|
119
130
|
|
120
131
|
def test_format_with_format_ltsv
|
121
132
|
config = [CONFIG, 'format ltsv'].join("\n")
|
122
133
|
d = create_driver(config)
|
123
134
|
|
124
|
-
time =
|
125
|
-
d.
|
126
|
-
|
127
|
-
|
128
|
-
|
129
|
-
d.
|
135
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
136
|
+
d.run(default_tag: "test") do
|
137
|
+
d.feed(time, {"a"=>1, "b"=>1})
|
138
|
+
d.feed(time, {"a"=>2, "b"=>2})
|
139
|
+
end
|
140
|
+
formatted = d.formatted
|
130
141
|
|
131
|
-
|
142
|
+
assert_equal %[a:1\tb:1\n], formatted[0]
|
143
|
+
assert_equal %[a:2\tb:2\n], formatted[1]
|
132
144
|
end
|
133
145
|
|
134
146
|
def test_format_with_format_json
|
135
147
|
config = [CONFIG, 'format json'].join("\n")
|
136
148
|
d = create_driver(config)
|
137
149
|
|
138
|
-
time =
|
139
|
-
d.
|
140
|
-
|
141
|
-
|
142
|
-
|
143
|
-
d.
|
150
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
151
|
+
d.run(default_tag: "test") do
|
152
|
+
d.feed(time, {"a"=>1})
|
153
|
+
d.feed(time, {"a"=>2})
|
154
|
+
end
|
155
|
+
formatted = d.formatted
|
144
156
|
|
145
|
-
|
157
|
+
assert_equal %[{"a":1}\n], formatted[0]
|
158
|
+
assert_equal %[{"a":2}\n], formatted[1]
|
146
159
|
end
|
147
160
|
|
148
161
|
def test_format_with_format_json_included_tag
|
149
162
|
config = [CONFIG, 'format json', 'include_tag_key true'].join("\n")
|
150
163
|
d = create_driver(config)
|
151
164
|
|
152
|
-
time =
|
153
|
-
d.
|
154
|
-
|
165
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
166
|
+
d.run(default_tag: "test") do
|
167
|
+
d.feed(time, {"a"=>1})
|
168
|
+
d.feed(time, {"a"=>2})
|
169
|
+
end
|
170
|
+
formatted = d.formatted
|
155
171
|
|
156
|
-
|
157
|
-
|
158
|
-
|
159
|
-
d.run
|
172
|
+
assert_equal %[{"a":1,"tag":"test"}\n], formatted[0]
|
173
|
+
assert_equal %[{"a":2,"tag":"test"}\n], formatted[1]
|
160
174
|
end
|
161
175
|
|
162
176
|
def test_format_with_format_json_included_time
|
163
177
|
config = [CONFIG, 'format json', 'include_time_key true'].join("\n")
|
164
178
|
d = create_driver(config)
|
165
179
|
|
166
|
-
time =
|
167
|
-
d.
|
168
|
-
|
169
|
-
|
170
|
-
|
171
|
-
d.
|
180
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
181
|
+
d.run(default_tag: "test") do
|
182
|
+
d.feed(time, {"a"=>1})
|
183
|
+
d.feed(time, {"a"=>2})
|
184
|
+
end
|
185
|
+
formatted = d.formatted
|
172
186
|
|
173
|
-
|
187
|
+
assert_equal %[{"a":1,"time":"2011-01-02T13:14:15Z"}\n], formatted[0]
|
188
|
+
assert_equal %[{"a":2,"time":"2011-01-02T13:14:15Z"}\n], formatted[1]
|
174
189
|
end
|
175
190
|
|
176
191
|
def test_format_with_format_json_included_tag_and_time
|
177
192
|
config = [CONFIG, 'format json', 'include_tag_key true', 'include_time_key true'].join("\n")
|
178
193
|
d = create_driver(config)
|
179
194
|
|
180
|
-
time =
|
181
|
-
d.
|
182
|
-
|
183
|
-
|
184
|
-
|
185
|
-
d.
|
195
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
196
|
+
d.run(default_tag: "test") do
|
197
|
+
d.feed(time, {"a"=>1})
|
198
|
+
d.feed(time, {"a"=>2})
|
199
|
+
end
|
200
|
+
formatted = d.formatted
|
186
201
|
|
187
|
-
|
202
|
+
assert_equal %[{"a":1,"tag":"test","time":"2011-01-02T13:14:15Z"}\n], formatted[0]
|
203
|
+
assert_equal %[{"a":2,"tag":"test","time":"2011-01-02T13:14:15Z"}\n], formatted[1]
|
188
204
|
end
|
189
205
|
|
190
206
|
def test_chunk_to_write
|
191
207
|
d = create_driver
|
192
208
|
|
193
|
-
time =
|
194
|
-
d.
|
195
|
-
|
209
|
+
time = event_time("2011-01-02 13:14:15 UTC")
|
210
|
+
d.run(default_tag: "test") do
|
211
|
+
d.feed(time, {"a"=>1})
|
212
|
+
d.feed(time, {"a"=>2})
|
213
|
+
end
|
196
214
|
|
197
|
-
#
|
198
|
-
data = d.
|
215
|
+
# Stubbed #write and #emit_streams returns chunk.read result.
|
216
|
+
data = d.instance.contents
|
199
217
|
|
200
218
|
assert_equal [%[2011-01-02T13:14:15Z\ttest\t{"a":1}\n] +
|
201
219
|
%[2011-01-02T13:14:15Z\ttest\t{"a":2}\n]],
|
@@ -216,7 +234,7 @@ class AzureStorageOutputTest < Test::Unit::TestCase
|
|
216
234
|
]
|
217
235
|
|
218
236
|
def create_time_sliced_driver(conf = CONFIG_TIME_SLICE)
|
219
|
-
d = Fluent::Test::
|
237
|
+
d = Fluent::Test::Driver::Output.new(Fluent::Plugin::AzureStorageOutput) do
|
220
238
|
end.configure(conf)
|
221
239
|
d
|
222
240
|
end
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: fluent-plugin-azurestorage
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.0
|
4
|
+
version: 0.1.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Hidemasa Togashi
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2017-
|
11
|
+
date: 2017-05-15 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: fluentd
|
@@ -16,7 +16,7 @@ dependencies:
|
|
16
16
|
requirements:
|
17
17
|
- - ">="
|
18
18
|
- !ruby/object:Gem::Version
|
19
|
-
version: 0.
|
19
|
+
version: 0.14.0
|
20
20
|
- - "<"
|
21
21
|
- !ruby/object:Gem::Version
|
22
22
|
version: '2'
|
@@ -26,7 +26,7 @@ dependencies:
|
|
26
26
|
requirements:
|
27
27
|
- - ">="
|
28
28
|
- !ruby/object:Gem::Version
|
29
|
-
version: 0.
|
29
|
+
version: 0.14.0
|
30
30
|
- - "<"
|
31
31
|
- !ruby/object:Gem::Version
|
32
32
|
version: '2'
|
@@ -51,19 +51,19 @@ dependencies:
|
|
51
51
|
- !ruby/object:Gem::Version
|
52
52
|
version: 0.7.7
|
53
53
|
- !ruby/object:Gem::Dependency
|
54
|
-
name:
|
54
|
+
name: uuidtools
|
55
55
|
requirement: !ruby/object:Gem::Requirement
|
56
56
|
requirements:
|
57
57
|
- - ">="
|
58
58
|
- !ruby/object:Gem::Version
|
59
|
-
version:
|
59
|
+
version: 2.1.5
|
60
60
|
type: :runtime
|
61
61
|
prerelease: false
|
62
62
|
version_requirements: !ruby/object:Gem::Requirement
|
63
63
|
requirements:
|
64
64
|
- - ">="
|
65
65
|
- !ruby/object:Gem::Version
|
66
|
-
version:
|
66
|
+
version: 2.1.5
|
67
67
|
- !ruby/object:Gem::Dependency
|
68
68
|
name: rake
|
69
69
|
requirement: !ruby/object:Gem::Requirement
|