logstash-integration-kafka 11.1.0-java → 11.2.1-java
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 +6 -0
- data/docs/input-kafka.asciidoc +54 -0
- data/lib/logstash/inputs/kafka.rb +18 -1
- data/lib/logstash/plugin_mixins/kafka/avro_schema_registry.rb +31 -0
- data/logstash-integration-kafka.gemspec +1 -1
- data/spec/integration/inputs/kafka_spec.rb +88 -20
- data/spec/unit/inputs/kafka_spec.rb +13 -0
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: fa69555ca0e7780e4f9ffb16c0a3308396bab86436009695708b1c03c11a7a96
|
4
|
+
data.tar.gz: e28f2ac5e38ba7cb1289496b3c633756a2607f2d37fee36fd19cc44b8abedf93
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: fc63838c9baf545fffc3984e94faf73e72d8b97d7b4c04af12fc8de639ca55500b50255d1fcf7c6f684e49a2db763adccddb93a3df495301d66a8be769fae3f1
|
7
|
+
data.tar.gz: 540628833b9b3ab000046c07d5143812ce945de4788c58cb66680ff9c8e9d9ebdff8cb82042c52e0bbfa5a806b2deeb04ece1e8bc5e16b73edcf7f38c3be9901
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,9 @@
|
|
1
|
+
## 11.2.1
|
2
|
+
- Fix nil exception to empty headers of record during event metadata assignment [#140](https://github.com/logstash-plugins/logstash-integration-kafka/pull/140)
|
3
|
+
|
4
|
+
## 11.2.0
|
5
|
+
- Added TLS truststore and keystore settings specifically to access the schema registry [#137](https://github.com/logstash-plugins/logstash-integration-kafka/pull/137)
|
6
|
+
|
1
7
|
## 11.1.0
|
2
8
|
- Added config `group_instance_id` to use the Kafka's consumer static membership feature [#135](https://github.com/logstash-plugins/logstash-integration-kafka/pull/135)
|
3
9
|
|
data/docs/input-kafka.asciidoc
CHANGED
@@ -135,6 +135,12 @@ See the https://kafka.apache.org/{kafka_client_doc}/documentation for more detai
|
|
135
135
|
| <<plugins-{type}s-{plugin}-schema_registry_key>> |<<string,string>>|No
|
136
136
|
| <<plugins-{type}s-{plugin}-schema_registry_proxy>> |<<uri,uri>>|No
|
137
137
|
| <<plugins-{type}s-{plugin}-schema_registry_secret>> |<<string,string>>|No
|
138
|
+
| <<plugins-{type}s-{plugin}-schema_registry_ssl_keystore_location>> |a valid filesystem path|No
|
139
|
+
| <<plugins-{type}s-{plugin}-schema_registry_ssl_keystore_password>> |<<password,password>>|No
|
140
|
+
| <<plugins-{type}s-{plugin}-schema_registry_ssl_keystore_type>> |<<string,string>>, one of `["jks", "PKCS12"]`|No
|
141
|
+
| <<plugins-{type}s-{plugin}-schema_registry_ssl_truststore_location>> |a valid filesystem path|No
|
142
|
+
| <<plugins-{type}s-{plugin}-schema_registry_ssl_truststore_password>> |<<password,password>>|No
|
143
|
+
| <<plugins-{type}s-{plugin}-schema_registry_ssl_truststore_type>> |<<string,string>>, one of `["jks", "PKCS12"]`|No
|
138
144
|
| <<plugins-{type}s-{plugin}-schema_registry_url>> |<<uri,uri>>|No
|
139
145
|
| <<plugins-{type}s-{plugin}-schema_registry_validation>> |<<string,string>>|No
|
140
146
|
| <<plugins-{type}s-{plugin}-security_protocol>> |<<string,string>>, one of `["PLAINTEXT", "SSL", "SASL_PLAINTEXT", "SASL_SSL"]`|No
|
@@ -598,6 +604,54 @@ Set the address of a forward HTTP proxy. An empty string is treated as if proxy
|
|
598
604
|
|
599
605
|
Set the password for basic authorization to access remote Schema Registry.
|
600
606
|
|
607
|
+
[id="plugins-{type}s-{plugin}-schema_registry_ssl_keystore_location"]
|
608
|
+
===== `schema_registry_ssl_keystore_location`
|
609
|
+
|
610
|
+
* Value type is <<path,path>>
|
611
|
+
* There is no default value for this setting.
|
612
|
+
|
613
|
+
If schema registry client authentication is required, this setting stores the keystore path.
|
614
|
+
|
615
|
+
[id="plugins-{type}s-{plugin}-schema_registry_ssl_keystore_password"]
|
616
|
+
===== `schema_registry_ssl_keystore_password`
|
617
|
+
|
618
|
+
* Value type is <<password,password>>
|
619
|
+
* There is no default value for this setting.
|
620
|
+
|
621
|
+
If schema registry authentication is required, this setting stores the keystore password.
|
622
|
+
|
623
|
+
[id="plugins-{type}s-{plugin}-schema_registry_ssl_keystore_type"]
|
624
|
+
===== `schema_registry_ssl_keystore_type`
|
625
|
+
|
626
|
+
* Value type is <<string,string>>
|
627
|
+
* There is no default value for this setting.
|
628
|
+
|
629
|
+
The format of the keystore file. It must be either `jks` or `PKCS12`.
|
630
|
+
|
631
|
+
[id="plugins-{type}s-{plugin}-schema_registry_ssl_truststore_location"]
|
632
|
+
===== `schema_registry_ssl_truststore_location`
|
633
|
+
|
634
|
+
* Value type is <<path,path>>
|
635
|
+
* There is no default value for this setting.
|
636
|
+
|
637
|
+
The truststore path to validate the schema registry's certificate.
|
638
|
+
|
639
|
+
[id="plugins-{type}s-{plugin}-schema_registry_ssl_truststore_password"]
|
640
|
+
===== `schema_registry_ssl_truststore_password`
|
641
|
+
|
642
|
+
* Value type is <<password,password>>
|
643
|
+
* There is no default value for this setting.
|
644
|
+
|
645
|
+
The schema registry truststore password.
|
646
|
+
|
647
|
+
[id="plugins-{type}s-{plugin}-schema_registry_ssl_truststore_type"]
|
648
|
+
===== `schema_registry_ssl_truststore_type`
|
649
|
+
|
650
|
+
* Value type is <<string,string>>
|
651
|
+
* There is no default value for this setting.
|
652
|
+
|
653
|
+
The format of the schema registry's truststore file. It must be either `jks` or `PKCS12`.
|
654
|
+
|
601
655
|
[id="plugins-{type}s-{plugin}-schema_registry_url"]
|
602
656
|
===== `schema_registry_url`
|
603
657
|
|
@@ -373,7 +373,9 @@ class LogStash::Inputs::Kafka < LogStash::Inputs::Base
|
|
373
373
|
event.set("[@metadata][kafka][timestamp]", record.timestamp)
|
374
374
|
end
|
375
375
|
if @metadata_mode.include?(:headers)
|
376
|
-
record.headers
|
376
|
+
record.headers
|
377
|
+
.select{|h| header_with_value(h) }
|
378
|
+
.each do |header|
|
377
379
|
s = String.from_java_bytes(header.value)
|
378
380
|
s.force_encoding(Encoding::UTF_8)
|
379
381
|
if s.valid_encoding?
|
@@ -460,6 +462,17 @@ class LogStash::Inputs::Kafka < LogStash::Inputs::Base
|
|
460
462
|
set_trustore_keystore_config(props)
|
461
463
|
set_sasl_config(props)
|
462
464
|
end
|
465
|
+
if schema_registry_ssl_truststore_location
|
466
|
+
props.put('schema.registry.ssl.truststore.location', schema_registry_ssl_truststore_location)
|
467
|
+
props.put('schema.registry.ssl.truststore.password', schema_registry_ssl_truststore_password.value)
|
468
|
+
props.put('schema.registry.ssl.truststore.type', schema_registry_ssl_truststore_type)
|
469
|
+
end
|
470
|
+
|
471
|
+
if schema_registry_ssl_keystore_location
|
472
|
+
props.put('schema.registry.ssl.keystore.location', schema_registry_ssl_keystore_location)
|
473
|
+
props.put('schema.registry.ssl.keystore.password', schema_registry_ssl_keystore_password.value)
|
474
|
+
props.put('schema.registry.ssl.keystore.type', schema_registry_ssl_keystore_type)
|
475
|
+
end
|
463
476
|
|
464
477
|
org.apache.kafka.clients.consumer.KafkaConsumer.new(props)
|
465
478
|
rescue => e
|
@@ -488,4 +501,8 @@ class LogStash::Inputs::Kafka < LogStash::Inputs::Base
|
|
488
501
|
end
|
489
502
|
end
|
490
503
|
|
504
|
+
def header_with_value(header)
|
505
|
+
!header.nil? && !header.value.nil? && !header.key.nil?
|
506
|
+
end
|
507
|
+
|
491
508
|
end #class LogStash::Inputs::Kafka
|
@@ -24,6 +24,24 @@ module LogStash module PluginMixins module Kafka
|
|
24
24
|
# This option permits to define a proxy to be used to reach the schema registry service instance.
|
25
25
|
config :schema_registry_proxy, :validate => :uri
|
26
26
|
|
27
|
+
# If schema registry client authentication is required, this setting stores the keystore path.
|
28
|
+
config :schema_registry_ssl_keystore_location, :validate => :string
|
29
|
+
|
30
|
+
# The keystore password.
|
31
|
+
config :schema_registry_ssl_keystore_password, :validate => :password
|
32
|
+
|
33
|
+
# The keystore type
|
34
|
+
config :schema_registry_ssl_keystore_type, :validate => ['jks', 'PKCS12'], :default => "jks"
|
35
|
+
|
36
|
+
# The JKS truststore path to validate the Schema Registry's certificate.
|
37
|
+
config :schema_registry_ssl_truststore_location, :validate => :string
|
38
|
+
|
39
|
+
# The truststore password.
|
40
|
+
config :schema_registry_ssl_truststore_password, :validate => :password
|
41
|
+
|
42
|
+
# The truststore type
|
43
|
+
config :schema_registry_ssl_truststore_type, :validate => ['jks', 'PKCS12'], :default => "jks"
|
44
|
+
|
27
45
|
# Option to skip validating the schema registry during registration. This can be useful when using
|
28
46
|
# certificate based auth
|
29
47
|
config :schema_registry_validation, :validate => ['auto', 'skip'], :default => 'auto'
|
@@ -68,6 +86,19 @@ module LogStash module PluginMixins module Kafka
|
|
68
86
|
if schema_registry_key and !schema_registry_key.empty?
|
69
87
|
options[:auth] = {:user => schema_registry_key, :password => schema_registry_secret.value}
|
70
88
|
end
|
89
|
+
if schema_registry_ssl_truststore_location and !schema_registry_ssl_truststore_location.empty?
|
90
|
+
options[:ssl] = {} unless options.key?(:ssl)
|
91
|
+
options[:ssl][:truststore] = schema_registry_ssl_truststore_location unless schema_registry_ssl_truststore_location.nil?
|
92
|
+
options[:ssl][:truststore_password] = schema_registry_ssl_truststore_password.value unless schema_registry_ssl_truststore_password.nil?
|
93
|
+
options[:ssl][:truststore_type] = schema_registry_ssl_truststore_type unless schema_registry_ssl_truststore_type.nil?
|
94
|
+
end
|
95
|
+
if schema_registry_ssl_keystore_location and !schema_registry_ssl_keystore_location.empty?
|
96
|
+
options[:ssl] = {} unless options.key? :ssl
|
97
|
+
options[:ssl][:keystore] = schema_registry_ssl_keystore_location unless schema_registry_ssl_keystore_location.nil?
|
98
|
+
options[:ssl][:keystore_password] = schema_registry_ssl_keystore_password.value unless schema_registry_ssl_keystore_password.nil?
|
99
|
+
options[:ssl][:keystore_type] = schema_registry_ssl_keystore_type unless schema_registry_ssl_keystore_type.nil?
|
100
|
+
end
|
101
|
+
|
71
102
|
client = Manticore::Client.new(options)
|
72
103
|
begin
|
73
104
|
response = client.get(@schema_registry_url.uri.to_s + '/subjects').body
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Gem::Specification.new do |s|
|
2
2
|
s.name = 'logstash-integration-kafka'
|
3
|
-
s.version = '11.1
|
3
|
+
s.version = '11.2.1'
|
4
4
|
s.licenses = ['Apache-2.0']
|
5
5
|
s.summary = "Integration with Kafka - input and output plugins"
|
6
6
|
s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline "+
|
@@ -353,10 +353,13 @@ describe "schema registry connection options" do
|
|
353
353
|
end
|
354
354
|
end
|
355
355
|
|
356
|
-
def save_avro_schema_to_schema_registry(schema_file, subject_name)
|
356
|
+
def save_avro_schema_to_schema_registry(schema_file, subject_name, proto = 'http', port = 8081, manticore_options = {})
|
357
357
|
raw_schema = File.readlines(schema_file).map(&:chomp).join
|
358
358
|
raw_schema_quoted = raw_schema.gsub('"', '\"')
|
359
|
-
|
359
|
+
|
360
|
+
client = Manticore::Client.new(manticore_options)
|
361
|
+
|
362
|
+
response = client.post("#{proto}://localhost:#{port}/subjects/#{subject_name}/versions",
|
360
363
|
body: '{"schema": "' + raw_schema_quoted + '"}',
|
361
364
|
headers: {"Content-Type" => "application/vnd.schemaregistry.v1+json"})
|
362
365
|
response
|
@@ -378,8 +381,17 @@ def startup_schema_registry(schema_registry, auth=false)
|
|
378
381
|
end
|
379
382
|
end
|
380
383
|
|
381
|
-
|
382
|
-
|
384
|
+
shared_examples 'it has endpoints available to' do |tls|
|
385
|
+
let(:port) { tls ? 8083 : 8081 }
|
386
|
+
let(:proto) { tls ? 'https' : 'http' }
|
387
|
+
|
388
|
+
manticore_options = {
|
389
|
+
:ssl => {
|
390
|
+
:truststore => File.join(Dir.pwd, "tls_repository/clienttruststore.jks"),
|
391
|
+
:truststore_password => "changeit"
|
392
|
+
}
|
393
|
+
}
|
394
|
+
schema_registry = Manticore::Client.new(manticore_options)
|
383
395
|
|
384
396
|
before(:all) do
|
385
397
|
startup_schema_registry(schema_registry)
|
@@ -391,36 +403,53 @@ describe "Schema registry API", :integration => true do
|
|
391
403
|
|
392
404
|
context 'listing subject on clean instance' do
|
393
405
|
it "should return an empty set" do
|
394
|
-
subjects = JSON.parse schema_registry.get(
|
406
|
+
subjects = JSON.parse schema_registry.get("#{proto}://localhost:#{port}/subjects").body
|
395
407
|
expect( subjects ).to be_empty
|
396
408
|
end
|
397
409
|
end
|
398
410
|
|
399
411
|
context 'send a schema definition' do
|
400
412
|
it "save the definition" do
|
401
|
-
response = save_avro_schema_to_schema_registry(File.join(Dir.pwd, "spec", "unit", "inputs", "avro_schema_fixture_payment.asvc"), "schema_test_1")
|
413
|
+
response = save_avro_schema_to_schema_registry(File.join(Dir.pwd, "spec", "unit", "inputs", "avro_schema_fixture_payment.asvc"), "schema_test_1", proto, port, manticore_options)
|
402
414
|
expect( response.code ).to be(200)
|
403
415
|
delete_remote_schema(schema_registry, "schema_test_1")
|
404
416
|
end
|
405
417
|
|
406
418
|
it "delete the schema just added" do
|
407
|
-
response = save_avro_schema_to_schema_registry(File.join(Dir.pwd, "spec", "unit", "inputs", "avro_schema_fixture_payment.asvc"), "schema_test_1")
|
419
|
+
response = save_avro_schema_to_schema_registry(File.join(Dir.pwd, "spec", "unit", "inputs", "avro_schema_fixture_payment.asvc"), "schema_test_1", proto, port, manticore_options)
|
408
420
|
expect( response.code ).to be(200)
|
409
421
|
|
410
|
-
expect( schema_registry.delete(
|
422
|
+
expect( schema_registry.delete("#{proto}://localhost:#{port}/subjects/schema_test_1?permanent=false").code ).to be(200)
|
411
423
|
sleep(1)
|
412
|
-
subjects = JSON.parse schema_registry.get(
|
424
|
+
subjects = JSON.parse schema_registry.get("#{proto}://localhost:#{port}/subjects").body
|
413
425
|
expect( subjects ).to be_empty
|
414
426
|
end
|
415
427
|
end
|
416
428
|
end
|
417
429
|
|
430
|
+
describe "Schema registry API", :integration => true do
|
431
|
+
|
432
|
+
context "when exposed with HTTPS" do
|
433
|
+
it_behaves_like 'it has endpoints available to', true
|
434
|
+
end
|
435
|
+
|
436
|
+
context "when exposed with plain HTTP" do
|
437
|
+
it_behaves_like 'it has endpoints available to', false
|
438
|
+
end
|
439
|
+
end
|
440
|
+
|
418
441
|
def shutdown_schema_registry
|
419
442
|
system('./stop_schema_registry.sh')
|
420
443
|
end
|
421
444
|
|
422
445
|
describe "Deserializing with the schema registry", :integration => true do
|
423
|
-
|
446
|
+
manticore_options = {
|
447
|
+
:ssl => {
|
448
|
+
:truststore => File.join(Dir.pwd, "tls_repository/clienttruststore.jks"),
|
449
|
+
:truststore_password => "changeit"
|
450
|
+
}
|
451
|
+
}
|
452
|
+
schema_registry = Manticore::Client.new(manticore_options)
|
424
453
|
|
425
454
|
shared_examples 'it reads from a topic using a schema registry' do |with_auth|
|
426
455
|
|
@@ -519,28 +548,57 @@ describe "Deserializing with the schema registry", :integration => true do
|
|
519
548
|
end
|
520
549
|
end
|
521
550
|
|
522
|
-
|
551
|
+
shared_examples 'with an unauthed schema registry' do |tls|
|
552
|
+
let(:port) { tls ? 8083 : 8081 }
|
553
|
+
let(:proto) { tls ? 'https' : 'http' }
|
554
|
+
|
523
555
|
let(:auth) { false }
|
524
556
|
let(:avro_topic_name) { "topic_avro" }
|
525
|
-
let(:subject_url) { "
|
526
|
-
let(:plain_config) { base_config.merge!({
|
557
|
+
let(:subject_url) { "#{proto}://localhost:#{port}/subjects" }
|
558
|
+
let(:plain_config) { base_config.merge!({
|
559
|
+
'schema_registry_url' => "#{proto}://localhost:#{port}",
|
560
|
+
'schema_registry_ssl_truststore_location' => File.join(Dir.pwd, "tls_repository/clienttruststore.jks"),
|
561
|
+
'schema_registry_ssl_truststore_password' => 'changeit',
|
562
|
+
}) }
|
527
563
|
|
528
564
|
it_behaves_like 'it reads from a topic using a schema registry', false
|
529
565
|
end
|
530
566
|
|
531
|
-
context 'with an
|
567
|
+
context 'with an unauthed schema registry' do
|
568
|
+
context "accessed through HTTPS" do
|
569
|
+
it_behaves_like 'with an unauthed schema registry', true
|
570
|
+
end
|
571
|
+
|
572
|
+
context "accessed through HTTPS" do
|
573
|
+
it_behaves_like 'with an unauthed schema registry', false
|
574
|
+
end
|
575
|
+
end
|
576
|
+
|
577
|
+
shared_examples 'with an authed schema registry' do |tls|
|
578
|
+
let(:port) { tls ? 8083 : 8081 }
|
579
|
+
let(:proto) { tls ? 'https' : 'http' }
|
532
580
|
let(:auth) { true }
|
533
581
|
let(:user) { "barney" }
|
534
582
|
let(:password) { "changeme" }
|
535
583
|
let(:avro_topic_name) { "topic_avro_auth" }
|
536
|
-
let(:subject_url) { "
|
584
|
+
let(:subject_url) { "#{proto}://#{user}:#{password}@localhost:#{port}/subjects" }
|
585
|
+
let(:tls_base_config) do
|
586
|
+
if tls
|
587
|
+
base_config.merge({
|
588
|
+
'schema_registry_ssl_truststore_location' => ::File.join(Dir.pwd, "tls_repository/clienttruststore.jks"),
|
589
|
+
'schema_registry_ssl_truststore_password' => 'changeit',
|
590
|
+
})
|
591
|
+
else
|
592
|
+
base_config
|
593
|
+
end
|
594
|
+
end
|
537
595
|
|
538
596
|
context 'using schema_registry_key' do
|
539
597
|
let(:plain_config) do
|
540
|
-
|
541
|
-
'schema_registry_url' => "
|
598
|
+
tls_base_config.merge!({
|
599
|
+
'schema_registry_url' => "#{proto}://localhost:#{port}",
|
542
600
|
'schema_registry_key' => user,
|
543
|
-
'schema_registry_secret' => password
|
601
|
+
'schema_registry_secret' => password,
|
544
602
|
})
|
545
603
|
end
|
546
604
|
|
@@ -549,12 +607,22 @@ describe "Deserializing with the schema registry", :integration => true do
|
|
549
607
|
|
550
608
|
context 'using schema_registry_url' do
|
551
609
|
let(:plain_config) do
|
552
|
-
|
553
|
-
'schema_registry_url' => "
|
610
|
+
tls_base_config.merge!({
|
611
|
+
'schema_registry_url' => "#{proto}://#{user}:#{password}@localhost:#{port}",
|
554
612
|
})
|
555
613
|
end
|
556
614
|
|
557
615
|
it_behaves_like 'it reads from a topic using a schema registry', true
|
558
616
|
end
|
559
617
|
end
|
618
|
+
|
619
|
+
context 'with an authed schema registry' do
|
620
|
+
context "accessed through HTTPS" do
|
621
|
+
it_behaves_like 'with an authed schema registry', true
|
622
|
+
end
|
623
|
+
|
624
|
+
context "accessed through HTTPS" do
|
625
|
+
it_behaves_like 'with an authed schema registry', false
|
626
|
+
end
|
627
|
+
end
|
560
628
|
end
|
@@ -287,6 +287,19 @@ describe LogStash::Inputs::Kafka do
|
|
287
287
|
subject.register
|
288
288
|
expect(subject.metadata_mode).to include(:record_props)
|
289
289
|
end
|
290
|
+
|
291
|
+
context "guards against nil header" do
|
292
|
+
let(:header) { double(:value => nil, :key => "k") }
|
293
|
+
let(:headers) { [ header ] }
|
294
|
+
let(:record) { double(:headers => headers, :topic => "topic", :partition => 0,
|
295
|
+
:offset => 123456789, :key => "someId", :timestamp => nil ) }
|
296
|
+
|
297
|
+
it "does not raise error when key is nil" do
|
298
|
+
subject.register
|
299
|
+
evt = LogStash::Event.new('message' => 'Hello')
|
300
|
+
expect { subject.maybe_set_metadata(evt, record) }.not_to raise_error
|
301
|
+
end
|
302
|
+
end
|
290
303
|
end
|
291
304
|
|
292
305
|
context 'with client_rack' do
|
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: logstash-integration-kafka
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 11.1
|
4
|
+
version: 11.2.1
|
5
5
|
platform: java
|
6
6
|
authors:
|
7
7
|
- Elastic
|
8
8
|
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2023-
|
11
|
+
date: 2023-02-16 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|