logstash-filter-translate 3.4.3 → 3.5.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +6 -0
- data/README.md +1 -1
- data/docs/index.asciidoc +17 -0
- data/lib/logstash/filters/dictionary/csv_file.rb +4 -2
- data/lib/logstash/filters/dictionary/file.rb +28 -5
- data/lib/logstash/filters/dictionary/json_file.rb +9 -2
- data/lib/logstash/filters/dictionary/streaming_yaml_parser.rb +111 -0
- data/lib/logstash/filters/dictionary/yaml_file.rb +20 -10
- data/lib/logstash/filters/translate.rb +8 -4
- data/logstash-filter-translate.gemspec +2 -2
- data/spec/filters/scheduling_spec.rb +179 -0
- data/spec/filters/translate_spec.rb +20 -5
- data/spec/fixtures/dict.yml +1 -0
- data/spec/support/build_huge_dictionaries.rb +4 -3
- data/version +1 -0
- metadata +15 -16
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bac5f7aca89b5e4261183c450ef1fb494ea29a48dc0c32157863a5176238663a
|
|
4
|
+
data.tar.gz: 24b5b67a25d7cefd48d04fd8d9789583166c748e6901d38a569d61cbc68f069c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 38ab5fecaf9a92700cae046e16dc0025b31be2adab0e2dc44609096cef043efff6afe069210010e8db3932c0710aadb29efe3fe879d5ad981345043b838b5ec6
|
|
7
|
+
data.tar.gz: c0d28aaaf7ab1c08c10ccec79bc25fef1e2c5e86fed295bed81ddcb1f0c84ae184917c867c37e6c9273eff681b5f34bded08b118a3936b279b99f0f1a673c22b
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 3.5.1
|
|
2
|
+
- Fixes an issue where failing to load a dictionary could cause the plugin to continue to run with a missing or partially-updated dictionary; this issue was especially noticeable when configured with `refresh_behaviour => replace`, which clears the dictionary before loading the replacement [#112](https://github.com/logstash-plugins/logstash-filter-translate/issues/112).
|
|
3
|
+
|
|
4
|
+
## 3.5.0
|
|
5
|
+
- Introduce opt-in "yaml_load_strategy => streaming" to stream parse YAML dictionaries [#106](https://github.com/logstash-plugins/logstash-filter-translate/pull/106)
|
|
6
|
+
|
|
1
7
|
## 3.4.3
|
|
2
8
|
- Allow YamlFile's Psych::Parser and Visitor instances to be garbage collected [#104](https://github.com/logstash-plugins/logstash-filter-translate/pull/104)
|
|
3
9
|
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Logstash Plugin
|
|
2
2
|
|
|
3
|
-
[](https://github.com/logstash-plugins/logstash-filter-translate/actions/workflows/unit-tests.yml)
|
|
4
4
|
|
|
5
5
|
This is a plugin for [Logstash](https://github.com/elastic/logstash).
|
|
6
6
|
|
data/docs/index.asciidoc
CHANGED
|
@@ -109,6 +109,7 @@ This plugin supports the following configuration options plus the <<plugins-{typ
|
|
|
109
109
|
| <<plugins-{type}s-{plugin}-refresh_behaviour>> |<<string,string>>|No
|
|
110
110
|
| <<plugins-{type}s-{plugin}-target>> |<<string,string>>|No
|
|
111
111
|
| <<plugins-{type}s-{plugin}-yaml_dictionary_code_point_limit>> |<<number,number>>|No
|
|
112
|
+
| <<plugins-{type}s-{plugin}-yaml_load_strategy>> |<<string,string>>, one of `["one_shot", "streaming"]`|No
|
|
112
113
|
|=======================================================================
|
|
113
114
|
|
|
114
115
|
Also see <<plugins-{type}s-{plugin}-common-options>> for a list of options supported by all
|
|
@@ -432,5 +433,21 @@ the filter will succeed. This will clobber the old value of the source field!
|
|
|
432
433
|
The max amount of code points in the YAML file in `dictionary_path`. Please be aware that byte limit depends on the encoding.
|
|
433
434
|
This setting is effective for YAML file only. YAML over the limit throws exception.
|
|
434
435
|
|
|
436
|
+
[id="plugins-{type}s-{plugin}-yaml_load_strategy"]
|
|
437
|
+
===== `yaml_load_strategy`
|
|
438
|
+
|
|
439
|
+
* Value can be any of: `one_shot`, `streaming`
|
|
440
|
+
* Default value is `one_shot`
|
|
441
|
+
|
|
442
|
+
How to load and parse the YAML file. This setting defaults to `one_shot`, which loads the entire
|
|
443
|
+
YAML file into the parser in one go, emitting the final dictionary from the fully parsed YAML document.
|
|
444
|
+
|
|
445
|
+
Setting to `streaming` will instead instruct the parser to emit one "YAML element" at a time, constructing the dictionary
|
|
446
|
+
during parsing. This mode drastically reduces the amount of memory required to load or refresh the dictionary and it is also faster.
|
|
447
|
+
|
|
448
|
+
Due to underlying implementation differences this mode only supports basic types such as Arrays, Objects, Strings, numbers and booleans, and does not support tags.
|
|
449
|
+
|
|
450
|
+
If you have a lot of translate filters with large YAML documents consider changing this setting to `streaming` instead.
|
|
451
|
+
|
|
435
452
|
[id="plugins-{type}s-{plugin}-common-options"]
|
|
436
453
|
include::{include_path}/{type}.asciidoc[]
|
|
@@ -6,9 +6,11 @@ module LogStash module Filters module Dictionary
|
|
|
6
6
|
|
|
7
7
|
protected
|
|
8
8
|
|
|
9
|
-
def
|
|
9
|
+
def read_dictionary
|
|
10
|
+
return enum_for(:read_dictionary) unless block_given?
|
|
11
|
+
|
|
10
12
|
::CSV.open(@dictionary_path, 'r:bom|utf-8') do |csv|
|
|
11
|
-
csv.each { |k,v|
|
|
13
|
+
csv.each { |k,v| yield(k,v) }
|
|
12
14
|
end
|
|
13
15
|
end
|
|
14
16
|
end
|
|
@@ -72,27 +72,50 @@ module LogStash module Filters module Dictionary
|
|
|
72
72
|
# sub class specific initializer
|
|
73
73
|
end
|
|
74
74
|
|
|
75
|
-
|
|
76
|
-
|
|
75
|
+
##
|
|
76
|
+
# read the dictionary from file into a new hash
|
|
77
|
+
# @yieldparam [String] key
|
|
78
|
+
# @yieldparam [Object] value
|
|
79
|
+
def read_dictionary
|
|
80
|
+
# defined in concrete implementation
|
|
81
|
+
fail NotImplementedError, "#{self.class} does not implement `read_dictionary`"
|
|
77
82
|
end
|
|
78
83
|
|
|
79
84
|
private
|
|
80
85
|
|
|
86
|
+
##
|
|
87
|
+
# merges the parsed contents of the dictionary file into the current dictionary.
|
|
88
|
+
# This is an all-or-nothing operation.
|
|
89
|
+
# @return [void]
|
|
81
90
|
def merge_dictionary
|
|
82
91
|
@write_lock.lock
|
|
83
92
|
begin
|
|
84
|
-
|
|
93
|
+
changes = {}
|
|
94
|
+
read_dictionary do |key, value|
|
|
95
|
+
changes[key] = value unless value == @dictionary[key]
|
|
96
|
+
end
|
|
97
|
+
@dictionary.update(changes)
|
|
85
98
|
@fetch_strategy.dictionary_updated
|
|
86
99
|
ensure
|
|
87
100
|
@write_lock.unlock
|
|
88
101
|
end
|
|
89
102
|
end
|
|
90
103
|
|
|
104
|
+
##
|
|
105
|
+
# replaces the current dictionary with the parsed contents of the dictionary file,
|
|
106
|
+
# using the existing value objects where possible to limit memory growth.
|
|
107
|
+
# This is an all-or-nothing operation.
|
|
108
|
+
# @return [void]
|
|
91
109
|
def replace_dictionary
|
|
92
110
|
@write_lock.lock
|
|
93
111
|
begin
|
|
94
|
-
|
|
95
|
-
|
|
112
|
+
replacement_dictionary = {}
|
|
113
|
+
read_dictionary do |key, value|
|
|
114
|
+
existing_value = @dictionary[key]
|
|
115
|
+
# if the value is equivalent to the existing value, use the existing value instead
|
|
116
|
+
replacement_dictionary[key] = (existing_value == value) ? existing_value : value
|
|
117
|
+
end
|
|
118
|
+
@dictionary.replace(replacement_dictionary)
|
|
96
119
|
@fetch_strategy.dictionary_updated
|
|
97
120
|
ensure
|
|
98
121
|
@write_lock.unlock
|
|
@@ -4,11 +4,18 @@ require "json"
|
|
|
4
4
|
module LogStash module Filters module Dictionary
|
|
5
5
|
class JsonFile < File
|
|
6
6
|
|
|
7
|
+
EMPTY = Hash.new.freeze
|
|
8
|
+
private_constant :EMPTY
|
|
9
|
+
|
|
7
10
|
protected
|
|
8
11
|
|
|
9
|
-
def
|
|
12
|
+
def read_dictionary
|
|
13
|
+
return enum_for(:read_dictionary) unless block_given?
|
|
14
|
+
|
|
10
15
|
content = IO.read(@dictionary_path, :mode => 'r:bom|utf-8')
|
|
11
|
-
|
|
16
|
+
return EMPTY if content.nil? || content.empty?
|
|
17
|
+
|
|
18
|
+
LogStash::Json.load(content).each_pair { |key, value| yield(key, value) }
|
|
12
19
|
end
|
|
13
20
|
end
|
|
14
21
|
end end end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
module LogStash module Filters module Dictionary
|
|
2
|
+
class StreamingYamlDictParser
|
|
3
|
+
def snakeYamlEngineV2
|
|
4
|
+
Java::org.snakeyaml.engine.v2
|
|
5
|
+
end
|
|
6
|
+
|
|
7
|
+
def snakeYamlEngineV2Events
|
|
8
|
+
snakeYamlEngineV2.events
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(filename, yaml_code_point_limit)
|
|
12
|
+
settings = snakeYamlEngineV2.api.LoadSettings.builder
|
|
13
|
+
.set_code_point_limit(yaml_code_point_limit)
|
|
14
|
+
.build
|
|
15
|
+
|
|
16
|
+
stream = Java::java.io.FileInputStream.new(filename)
|
|
17
|
+
reader = Java::java.io.InputStreamReader.new(stream, Java::java.nio.charset.StandardCharsets::UTF_8)
|
|
18
|
+
stream_reader = snakeYamlEngineV2.scanner.StreamReader.new(reader, settings)
|
|
19
|
+
|
|
20
|
+
@parser = snakeYamlEngineV2.parser.ParserImpl.new(stream_reader, settings)
|
|
21
|
+
|
|
22
|
+
skip_until(snakeYamlEngineV2Events.MappingStartEvent)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def each_pair
|
|
27
|
+
while peek_event && !peek_event.is_a?(snakeYamlEngineV2Events.MappingEndEvent)
|
|
28
|
+
key = parse_node
|
|
29
|
+
value = parse_node
|
|
30
|
+
yield(key, value)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def next_event
|
|
37
|
+
@parser.next
|
|
38
|
+
ensure
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def peek_event
|
|
43
|
+
@parser.peek_event
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def skip_until(event_class)
|
|
47
|
+
while @parser.has_next
|
|
48
|
+
evt = @parser.next
|
|
49
|
+
return if event_class === evt
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def parse_node
|
|
54
|
+
event = next_event
|
|
55
|
+
|
|
56
|
+
case event
|
|
57
|
+
when snakeYamlEngineV2Events.ScalarEvent
|
|
58
|
+
parse_scalar(event)
|
|
59
|
+
when snakeYamlEngineV2Events.MappingStartEvent
|
|
60
|
+
parse_mapping
|
|
61
|
+
when snakeYamlEngineV2Events.SequenceStartEvent
|
|
62
|
+
parse_sequence
|
|
63
|
+
else
|
|
64
|
+
raise "Unexpected event: #{event.class}"
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def parse_mapping
|
|
69
|
+
hash = {}
|
|
70
|
+
while peek_event && !peek_event.is_a?(snakeYamlEngineV2Events.MappingEndEvent)
|
|
71
|
+
key = parse_node
|
|
72
|
+
value = parse_node
|
|
73
|
+
hash[key] = value
|
|
74
|
+
end
|
|
75
|
+
next_event
|
|
76
|
+
hash
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def parse_sequence
|
|
80
|
+
array = []
|
|
81
|
+
while peek_event && !peek_event.is_a?(snakeYamlEngineV2Events.SequenceEndEvent)
|
|
82
|
+
array << parse_node
|
|
83
|
+
end
|
|
84
|
+
next_event
|
|
85
|
+
array
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def parse_scalar(scalar)
|
|
89
|
+
value = scalar.value
|
|
90
|
+
# return quoted scalars as they are
|
|
91
|
+
# e.g. don't convert "true" to true
|
|
92
|
+
return value unless scalar.is_plain
|
|
93
|
+
|
|
94
|
+
# otherwise let's do some checking and conversions
|
|
95
|
+
case value
|
|
96
|
+
when 'null', '', '~' then nil
|
|
97
|
+
when 'true' then true
|
|
98
|
+
when 'false' then false
|
|
99
|
+
else
|
|
100
|
+
# Try to convert to integer or float
|
|
101
|
+
if value.match?(/\A-?\d+\z/)
|
|
102
|
+
value.to_i
|
|
103
|
+
elsif value.match?(/\A-?\d+\.\d+\z/)
|
|
104
|
+
value.to_f
|
|
105
|
+
else
|
|
106
|
+
value
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end end end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# encoding: utf-8
|
|
2
2
|
|
|
3
3
|
require_relative "yaml_visitor"
|
|
4
|
+
require_relative "streaming_yaml_parser"
|
|
4
5
|
|
|
5
6
|
module LogStash module Filters module Dictionary
|
|
6
7
|
class YamlFile < File
|
|
@@ -9,18 +10,27 @@ module LogStash module Filters module Dictionary
|
|
|
9
10
|
|
|
10
11
|
def initialize_for_file_type(**file_type_args)
|
|
11
12
|
@yaml_code_point_limit = file_type_args[:yaml_code_point_limit]
|
|
13
|
+
@yaml_load_strategy = file_type_args[:yaml_load_strategy]
|
|
12
14
|
end
|
|
13
15
|
|
|
14
|
-
def
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
16
|
+
def read_dictionary
|
|
17
|
+
return enum_for(:read_dictionary) unless block_given?
|
|
18
|
+
|
|
19
|
+
if @yaml_load_strategy == "one_shot"
|
|
20
|
+
visitor = YamlVisitor.create
|
|
21
|
+
parser = Psych::Parser.new(Psych::TreeBuilder.new)
|
|
22
|
+
parser.code_point_limit = @yaml_code_point_limit
|
|
23
|
+
# low level YAML read that tries to create as
|
|
24
|
+
# few intermediate objects as possible
|
|
25
|
+
yaml_string = IO.read(@dictionary_path, :mode => 'r:bom|utf-8')
|
|
26
|
+
parser.parse(yaml_string, @dictionary_path)
|
|
27
|
+
temp_dictionary = {}
|
|
28
|
+
visitor.accept_with_dictionary(temp_dictionary, parser.handler.root)
|
|
29
|
+
temp_dictionary.each_pair { |key, value| yield(key, value) }
|
|
30
|
+
else # stream parse it
|
|
31
|
+
parser = StreamingYamlDictParser.new(@dictionary_path, @yaml_code_point_limit)
|
|
32
|
+
parser.each_pair {|key, value| yield(key, value) }
|
|
33
|
+
end
|
|
24
34
|
end
|
|
25
35
|
end
|
|
26
36
|
end end end
|
|
@@ -108,6 +108,10 @@ class Translate < LogStash::Filters::Base
|
|
|
108
108
|
# The default value is 128MB for code points of size 1 byte
|
|
109
109
|
config :yaml_dictionary_code_point_limit, :validate => :number
|
|
110
110
|
|
|
111
|
+
# either load the entire yaml into memory before generating the in-memory dictionary
|
|
112
|
+
# alternatively "streaming" will gradually build the dictionary with little memory overhead
|
|
113
|
+
config :yaml_load_strategy, :validate => ["streaming", "one_shot"], :default => "one_shot"
|
|
114
|
+
|
|
111
115
|
# When using a dictionary file, this setting will indicate how frequently
|
|
112
116
|
# (in seconds) logstash will check the dictionary file for updates.
|
|
113
117
|
config :refresh_interval, :validate => :number, :default => 300
|
|
@@ -195,7 +199,7 @@ class Translate < LogStash::Filters::Base
|
|
|
195
199
|
if @yaml_dictionary_code_point_limit <= 0
|
|
196
200
|
raise LogStash::ConfigurationError, "Please set a positive number in `yaml_dictionary_code_point_limit => #{@yaml_dictionary_code_point_limit}`."
|
|
197
201
|
else
|
|
198
|
-
@lookup = Dictionary::File.create(@dictionary_path, @refresh_interval, @refresh_behaviour, @exact, @regex, yaml_code_point_limit: @yaml_dictionary_code_point_limit)
|
|
202
|
+
@lookup = Dictionary::File.create(@dictionary_path, @refresh_interval, @refresh_behaviour, @exact, @regex, yaml_code_point_limit: @yaml_dictionary_code_point_limit, yaml_load_strategy: @yaml_load_strategy)
|
|
199
203
|
end
|
|
200
204
|
elsif @yaml_dictionary_code_point_limit != nil
|
|
201
205
|
raise LogStash::ConfigurationError, "Please remove `yaml_dictionary_code_point_limit` for dictionary file in JSON or CSV format"
|
|
@@ -245,11 +249,11 @@ class Translate < LogStash::Filters::Base
|
|
|
245
249
|
@updater = ArrayOfMapsValueUpdate.new(@iterate_on, @source, @target, @fallback, @lookup)
|
|
246
250
|
end
|
|
247
251
|
|
|
248
|
-
|
|
252
|
+
logger.debug? && logger.debug("#{self.class.name}: Dictionary - ", :dictionary => @lookup.dictionary)
|
|
249
253
|
if @exact
|
|
250
|
-
|
|
254
|
+
logger.debug? && logger.debug("#{self.class.name}: Dictionary translation method - Exact")
|
|
251
255
|
else
|
|
252
|
-
|
|
256
|
+
logger.debug? && logger.debug("#{self.class.name}: Dictionary translation method - Fuzzy")
|
|
253
257
|
end
|
|
254
258
|
|
|
255
259
|
if @lookup.respond_to?(:reload_dictionary) && @refresh_interval > 0 # a scheduler interval of zero makes no sense
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Gem::Specification.new do |s|
|
|
2
2
|
|
|
3
3
|
s.name = 'logstash-filter-translate'
|
|
4
|
-
s.version = '
|
|
4
|
+
s.version = ::File.read('version').split("\n").first
|
|
5
5
|
s.licenses = ['Apache License (2.0)']
|
|
6
6
|
s.summary = "Replaces field contents based on a hash or YAML file"
|
|
7
7
|
s.description = "This gem is a Logstash plugin required to be installed on top of the Logstash core pipeline using $LS_HOME/bin/logstash-plugin install gemname. This gem is not a stand-alone program"
|
|
@@ -11,7 +11,7 @@ Gem::Specification.new do |s|
|
|
|
11
11
|
s.require_paths = ["lib"]
|
|
12
12
|
|
|
13
13
|
# Files
|
|
14
|
-
s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "docs/**/*"]
|
|
14
|
+
s.files = Dir["lib/**/*","spec/**/*","*.gemspec","*.md","CONTRIBUTORS","Gemfile","LICENSE","NOTICE.TXT", "vendor/jar-dependencies/**/*.jar", "vendor/jar-dependencies/**/*.rb", "VERSION", "version", "docs/**/*"]
|
|
15
15
|
|
|
16
16
|
# Tests
|
|
17
17
|
s.test_files = s.files.grep(%r{^(test|spec|features)/})
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
require 'rspec/wait'
|
|
3
4
|
require "logstash/devutils/rspec/spec_helper"
|
|
4
5
|
require "support/rspec_wait_handler_helper"
|
|
@@ -35,6 +36,7 @@ describe LogStash::Filters::Translate do
|
|
|
35
36
|
file.puts("a,1\nb,2\nc,3\n")
|
|
36
37
|
end
|
|
37
38
|
subject.register
|
|
39
|
+
allow(subject.lookup).to receive(:logger).and_return(double("LookupLogger").as_null_object)
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
after do
|
|
@@ -71,6 +73,95 @@ describe LogStash::Filters::Translate do
|
|
|
71
73
|
actions.activate_quietly
|
|
72
74
|
actions.assert_no_errors
|
|
73
75
|
end
|
|
76
|
+
|
|
77
|
+
it 'uses the replacement dictionary after reload' do
|
|
78
|
+
actions = RSpec::Sequencing
|
|
79
|
+
.run("translate") do
|
|
80
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
81
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
82
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
83
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
84
|
+
|
|
85
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
86
|
+
|
|
87
|
+
expect(event_a.get("translation")).to eq('1')
|
|
88
|
+
expect(event_b.get("translation")).to eq('2')
|
|
89
|
+
expect(event_c.get("translation")).to eq('3')
|
|
90
|
+
expect(event_d.get("translation")).to be_nil
|
|
91
|
+
end
|
|
92
|
+
.then("modify file with new CSV") do
|
|
93
|
+
dictionary_path.open("w") do |file|
|
|
94
|
+
file.puts("a,11\nb,12\nd,14\n") # changes a+b, removes c, adds d
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
.then_after(2, "translate again, ensuring we use the replacement dictionary") do
|
|
98
|
+
|
|
99
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
100
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
101
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
102
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
103
|
+
|
|
104
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
105
|
+
|
|
106
|
+
expect(event_a.get("translation")).to eq('11')
|
|
107
|
+
expect(event_b.get("translation")).to eq('12')
|
|
108
|
+
expect(event_c.get("translation")).to be_nil # not present in updated dict
|
|
109
|
+
expect(event_d.get("translation")).to eq('14')
|
|
110
|
+
end
|
|
111
|
+
.then("stop") do
|
|
112
|
+
subject.close
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
actions.activate_quietly
|
|
116
|
+
actions.assert_no_errors
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
context "when replacement file is corrupt" do
|
|
120
|
+
|
|
121
|
+
it "logs a warning with the parse error but keeps processing with existing definitions" do
|
|
122
|
+
actions = RSpec::Sequencing
|
|
123
|
+
.run("translate") do
|
|
124
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
125
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
126
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
127
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
128
|
+
|
|
129
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
130
|
+
|
|
131
|
+
expect(event_a.get("translation")).to eq('1')
|
|
132
|
+
expect(event_b.get("translation")).to eq('2')
|
|
133
|
+
expect(event_c.get("translation")).to eq('3')
|
|
134
|
+
expect(event_d.get("translation")).to be_nil
|
|
135
|
+
end
|
|
136
|
+
.then("modify file with invalid CSV") do
|
|
137
|
+
dictionary_path.open("w") do |file|
|
|
138
|
+
file.puts("a,11\nb,12\n\"\xFF".dup.force_encoding("UTF-8")) # intentional broken utf-8
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
.then_after(2, "wait for dictionary reload attempt and ensure logs were emitted") do
|
|
142
|
+
wait_for { subject.lookup.logger }.to have_received(:warn).with(/continuing with old dictionary/, anything)
|
|
143
|
+
end
|
|
144
|
+
.then("translate again, ensuring we still use the old dictionary") do
|
|
145
|
+
|
|
146
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
147
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
148
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
149
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
150
|
+
|
|
151
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
152
|
+
|
|
153
|
+
expect(event_a.get("translation")).to eq('1')
|
|
154
|
+
expect(event_b.get("translation")).to eq('2')
|
|
155
|
+
expect(event_c.get("translation")).to eq('3')
|
|
156
|
+
expect(event_d.get("translation")).to be_nil
|
|
157
|
+
end
|
|
158
|
+
.then("stop") do
|
|
159
|
+
subject.close
|
|
160
|
+
end
|
|
161
|
+
actions.activate_quietly
|
|
162
|
+
actions.assert_no_errors
|
|
163
|
+
end
|
|
164
|
+
end
|
|
74
165
|
end
|
|
75
166
|
|
|
76
167
|
context "merge" do
|
|
@@ -102,6 +193,92 @@ describe LogStash::Filters::Translate do
|
|
|
102
193
|
actions.activate_quietly
|
|
103
194
|
actions.assert_no_errors
|
|
104
195
|
end
|
|
196
|
+
|
|
197
|
+
it 'uses the merged dictionary after reload' do
|
|
198
|
+
actions = RSpec::Sequencing
|
|
199
|
+
.run("translate") do
|
|
200
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
201
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
202
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
203
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
204
|
+
|
|
205
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
206
|
+
|
|
207
|
+
expect(event_a.get("translation")).to eq('1')
|
|
208
|
+
expect(event_b.get("translation")).to eq('2')
|
|
209
|
+
expect(event_c.get("translation")).to eq('3')
|
|
210
|
+
expect(event_d.get("translation")).to be_nil
|
|
211
|
+
end
|
|
212
|
+
.then("modify file with new CSV") do
|
|
213
|
+
dictionary_path.open("w") do |file|
|
|
214
|
+
file.puts("a,11\nb,12\nd,14\n") # changes a+b, removes c, adds d
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
.then_after(2, "translate again, ensuring we use the merged dictionary") do
|
|
218
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
219
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
220
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
221
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
222
|
+
|
|
223
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
224
|
+
|
|
225
|
+
expect(event_a.get("translation")).to eq('11')
|
|
226
|
+
expect(event_b.get("translation")).to eq('12')
|
|
227
|
+
expect(event_c.get("translation")).to eq('3') # deleted in update, uses old value after merge
|
|
228
|
+
expect(event_d.get("translation")).to eq('14')
|
|
229
|
+
end
|
|
230
|
+
.then("stop") do
|
|
231
|
+
subject.close
|
|
232
|
+
end
|
|
233
|
+
actions.activate_quietly
|
|
234
|
+
actions.assert_no_errors
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
context "when replacement file is corrupt" do
|
|
238
|
+
|
|
239
|
+
it "logs a warning with the parse error but keeps processing with existing definitions" do
|
|
240
|
+
actions = RSpec::Sequencing
|
|
241
|
+
.run("translate") do
|
|
242
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
243
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
244
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
245
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
246
|
+
|
|
247
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
248
|
+
|
|
249
|
+
expect(event_a.get("translation")).to eq('1')
|
|
250
|
+
expect(event_b.get("translation")).to eq('2')
|
|
251
|
+
expect(event_c.get("translation")).to eq('3')
|
|
252
|
+
expect(event_d.get("translation")).to be_nil
|
|
253
|
+
end
|
|
254
|
+
.then("modify file with invalid CSV") do
|
|
255
|
+
dictionary_path.open("w") do |file|
|
|
256
|
+
file.puts("a,11\nb,12\n\"\xFF".dup.force_encoding("UTF-8")) # intentional broken utf-8
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
.then_after(2, "wait for dictionary reload attempt and ensure logs were emitted") do
|
|
260
|
+
wait_for { subject.lookup.logger }.to have_received(:warn).with(/continuing with old dictionary/, anything)
|
|
261
|
+
end
|
|
262
|
+
.then("translate again, ensuring we still use the old dictionary") do
|
|
263
|
+
event_a = LogStash::Event.new("status" => "a" )
|
|
264
|
+
event_b = LogStash::Event.new("status" => "b" )
|
|
265
|
+
event_c = LogStash::Event.new("status" => "c" )
|
|
266
|
+
event_d = LogStash::Event.new("status" => "d" )
|
|
267
|
+
|
|
268
|
+
subject.multi_filter([event_a, event_b, event_c, event_d])
|
|
269
|
+
|
|
270
|
+
expect(event_a.get("translation")).to eq('1')
|
|
271
|
+
expect(event_b.get("translation")).to eq('2')
|
|
272
|
+
expect(event_c.get("translation")).to eq('3')
|
|
273
|
+
expect(event_d.get("translation")).to be_nil
|
|
274
|
+
end
|
|
275
|
+
.then("stop") do
|
|
276
|
+
subject.close
|
|
277
|
+
end
|
|
278
|
+
actions.activate_quietly
|
|
279
|
+
actions.assert_no_errors
|
|
280
|
+
end
|
|
281
|
+
end
|
|
105
282
|
end
|
|
106
283
|
end
|
|
107
284
|
|
|
@@ -127,6 +304,7 @@ describe LogStash::Filters::Translate do
|
|
|
127
304
|
directory
|
|
128
305
|
wait(1.0).for{Dir.exist?(directory)}.to eq(true)
|
|
129
306
|
LogStash::Filters::Dictionary.create_huge_json_dictionary(directory, "dict-h.json", dictionary_size)
|
|
307
|
+
allow(subject).to receive(:logger).and_return(double("Logger").as_null_object)
|
|
130
308
|
subject.register
|
|
131
309
|
end
|
|
132
310
|
|
|
@@ -176,6 +354,7 @@ describe LogStash::Filters::Translate do
|
|
|
176
354
|
directory
|
|
177
355
|
wait(1.0).for{Dir.exist?(directory)}.to eq(true)
|
|
178
356
|
LogStash::Filters::Dictionary.create_huge_csv_dictionary(directory, "dict-h.csv", dictionary_size)
|
|
357
|
+
allow(subject).to receive(:logger).and_return(double("Logger").as_null_object)
|
|
179
358
|
subject.register
|
|
180
359
|
end
|
|
181
360
|
|
|
@@ -238,6 +238,21 @@ describe LogStash::Filters::Translate do
|
|
|
238
238
|
subject.filter(event)
|
|
239
239
|
expect(event.get("translation")).to eq(1)
|
|
240
240
|
end
|
|
241
|
+
|
|
242
|
+
describe "yaml_load_strategy" do
|
|
243
|
+
let(:one_shot_parse_filter) { subject }
|
|
244
|
+
let(:streaming_parse_filter) { described_class.new(config.merge("yaml_load_strategy" => 'streaming')) }
|
|
245
|
+
|
|
246
|
+
before(:each) do
|
|
247
|
+
subject.register
|
|
248
|
+
streaming_parse_filter.register
|
|
249
|
+
end
|
|
250
|
+
let(:one_shot_dictionary) { one_shot_parse_filter.lookup.dictionary }
|
|
251
|
+
let(:streaming_dictionary) { streaming_parse_filter.lookup.dictionary }
|
|
252
|
+
it "produces an equivalent dictionary for both strategies" do
|
|
253
|
+
expect(one_shot_dictionary).to eq(streaming_dictionary)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
241
256
|
end
|
|
242
257
|
|
|
243
258
|
describe "when using a yml dictionary with code point limit" do
|
|
@@ -246,14 +261,16 @@ describe LogStash::Filters::Translate do
|
|
|
246
261
|
"source" => "status",
|
|
247
262
|
"target" => "translation",
|
|
248
263
|
"dictionary_path" => dictionary_path,
|
|
249
|
-
"yaml_dictionary_code_point_limit" =>
|
|
264
|
+
"yaml_dictionary_code_point_limit" => codepoint_limit
|
|
250
265
|
}
|
|
251
266
|
end
|
|
252
267
|
let(:dictionary_path) { TranslateUtil.build_fixture_path("dict.yml") }
|
|
268
|
+
let(:dictionary_size) { IO.read(dictionary_path).size }
|
|
253
269
|
let(:event) { LogStash::Event.new("status" => "a") }
|
|
270
|
+
let(:codepoint_limit) { dictionary_size }
|
|
254
271
|
|
|
255
|
-
context "
|
|
256
|
-
let(:
|
|
272
|
+
context "codepoint limit under dictionary size" do
|
|
273
|
+
let(:codepoint_limit) { dictionary_size / 2 }
|
|
257
274
|
|
|
258
275
|
it "raises exception" do
|
|
259
276
|
expect { subject.register }.to raise_error(/The incoming YAML document exceeds/)
|
|
@@ -261,8 +278,6 @@ describe LogStash::Filters::Translate do
|
|
|
261
278
|
end
|
|
262
279
|
|
|
263
280
|
context "dictionary is within limit" do
|
|
264
|
-
let(:dictionary_size) { 18 }
|
|
265
|
-
|
|
266
281
|
it "returns the exact translation" do
|
|
267
282
|
subject.register
|
|
268
283
|
subject.filter(event)
|
data/spec/fixtures/dict.yml
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
2
3
|
|
|
3
4
|
require "securerandom"
|
|
4
5
|
|
|
@@ -20,10 +21,10 @@ module LogStash module Filters module Dictionary
|
|
|
20
21
|
tmppath = directory.join("temp_big.json")
|
|
21
22
|
tmppath.open("w") do |file|
|
|
22
23
|
file.puts("{")
|
|
23
|
-
file.puts(
|
|
24
|
-
file.puts(
|
|
24
|
+
file.puts(%Q( "foo":"#{SecureRandom.hex(4)}",))
|
|
25
|
+
file.puts(%Q( "bar":"#{SecureRandom.hex(4)}",))
|
|
25
26
|
size.times do |i|
|
|
26
|
-
file.puts(
|
|
27
|
+
file.puts(%Q( "#{SecureRandom.hex(12)}":"#{1000000 + i}",))
|
|
27
28
|
end
|
|
28
29
|
file.puts(' "baz":"quux"')
|
|
29
30
|
file.puts("}")
|
data/version
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
3.5.1
|
metadata
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: logstash-filter-translate
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 3.
|
|
4
|
+
version: 3.5.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Elastic
|
|
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
|
|
13
|
+
name: logstash-core-plugin-api
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
16
|
- - ">="
|
|
@@ -19,7 +19,6 @@ dependencies:
|
|
|
19
19
|
- - "<="
|
|
20
20
|
- !ruby/object:Gem::Version
|
|
21
21
|
version: '2.99'
|
|
22
|
-
name: logstash-core-plugin-api
|
|
23
22
|
type: :runtime
|
|
24
23
|
prerelease: false
|
|
25
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -31,12 +30,12 @@ dependencies:
|
|
|
31
30
|
- !ruby/object:Gem::Version
|
|
32
31
|
version: '2.99'
|
|
33
32
|
- !ruby/object:Gem::Dependency
|
|
33
|
+
name: logstash-mixin-ecs_compatibility_support
|
|
34
34
|
requirement: !ruby/object:Gem::Requirement
|
|
35
35
|
requirements:
|
|
36
36
|
- - "~>"
|
|
37
37
|
- !ruby/object:Gem::Version
|
|
38
38
|
version: '1.2'
|
|
39
|
-
name: logstash-mixin-ecs_compatibility_support
|
|
40
39
|
type: :runtime
|
|
41
40
|
prerelease: false
|
|
42
41
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -45,12 +44,12 @@ dependencies:
|
|
|
45
44
|
- !ruby/object:Gem::Version
|
|
46
45
|
version: '1.2'
|
|
47
46
|
- !ruby/object:Gem::Dependency
|
|
47
|
+
name: logstash-mixin-validator_support
|
|
48
48
|
requirement: !ruby/object:Gem::Requirement
|
|
49
49
|
requirements:
|
|
50
50
|
- - "~>"
|
|
51
51
|
- !ruby/object:Gem::Version
|
|
52
52
|
version: '1.0'
|
|
53
|
-
name: logstash-mixin-validator_support
|
|
54
53
|
type: :runtime
|
|
55
54
|
prerelease: false
|
|
56
55
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -59,12 +58,12 @@ dependencies:
|
|
|
59
58
|
- !ruby/object:Gem::Version
|
|
60
59
|
version: '1.0'
|
|
61
60
|
- !ruby/object:Gem::Dependency
|
|
61
|
+
name: logstash-mixin-deprecation_logger_support
|
|
62
62
|
requirement: !ruby/object:Gem::Requirement
|
|
63
63
|
requirements:
|
|
64
64
|
- - "~>"
|
|
65
65
|
- !ruby/object:Gem::Version
|
|
66
66
|
version: '1.0'
|
|
67
|
-
name: logstash-mixin-deprecation_logger_support
|
|
68
67
|
type: :runtime
|
|
69
68
|
prerelease: false
|
|
70
69
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -73,12 +72,12 @@ dependencies:
|
|
|
73
72
|
- !ruby/object:Gem::Version
|
|
74
73
|
version: '1.0'
|
|
75
74
|
- !ruby/object:Gem::Dependency
|
|
75
|
+
name: logstash-mixin-scheduler
|
|
76
76
|
requirement: !ruby/object:Gem::Requirement
|
|
77
77
|
requirements:
|
|
78
78
|
- - "~>"
|
|
79
79
|
- !ruby/object:Gem::Version
|
|
80
80
|
version: '1.0'
|
|
81
|
-
name: logstash-mixin-scheduler
|
|
82
81
|
type: :runtime
|
|
83
82
|
prerelease: false
|
|
84
83
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -87,12 +86,12 @@ dependencies:
|
|
|
87
86
|
- !ruby/object:Gem::Version
|
|
88
87
|
version: '1.0'
|
|
89
88
|
- !ruby/object:Gem::Dependency
|
|
89
|
+
name: psych
|
|
90
90
|
requirement: !ruby/object:Gem::Requirement
|
|
91
91
|
requirements:
|
|
92
92
|
- - ">="
|
|
93
93
|
- !ruby/object:Gem::Version
|
|
94
94
|
version: 5.1.0
|
|
95
|
-
name: psych
|
|
96
95
|
type: :runtime
|
|
97
96
|
prerelease: false
|
|
98
97
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -101,12 +100,12 @@ dependencies:
|
|
|
101
100
|
- !ruby/object:Gem::Version
|
|
102
101
|
version: 5.1.0
|
|
103
102
|
- !ruby/object:Gem::Dependency
|
|
103
|
+
name: logstash-devutils
|
|
104
104
|
requirement: !ruby/object:Gem::Requirement
|
|
105
105
|
requirements:
|
|
106
106
|
- - ">="
|
|
107
107
|
- !ruby/object:Gem::Version
|
|
108
108
|
version: '0'
|
|
109
|
-
name: logstash-devutils
|
|
110
109
|
type: :development
|
|
111
110
|
prerelease: false
|
|
112
111
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -115,12 +114,12 @@ dependencies:
|
|
|
115
114
|
- !ruby/object:Gem::Version
|
|
116
115
|
version: '0'
|
|
117
116
|
- !ruby/object:Gem::Dependency
|
|
117
|
+
name: rspec-sequencing
|
|
118
118
|
requirement: !ruby/object:Gem::Requirement
|
|
119
119
|
requirements:
|
|
120
120
|
- - ">="
|
|
121
121
|
- !ruby/object:Gem::Version
|
|
122
122
|
version: '0'
|
|
123
|
-
name: rspec-sequencing
|
|
124
123
|
type: :development
|
|
125
124
|
prerelease: false
|
|
126
125
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -129,12 +128,12 @@ dependencies:
|
|
|
129
128
|
- !ruby/object:Gem::Version
|
|
130
129
|
version: '0'
|
|
131
130
|
- !ruby/object:Gem::Dependency
|
|
131
|
+
name: rspec-wait
|
|
132
132
|
requirement: !ruby/object:Gem::Requirement
|
|
133
133
|
requirements:
|
|
134
134
|
- - ">="
|
|
135
135
|
- !ruby/object:Gem::Version
|
|
136
136
|
version: '0'
|
|
137
|
-
name: rspec-wait
|
|
138
137
|
type: :development
|
|
139
138
|
prerelease: false
|
|
140
139
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -143,12 +142,12 @@ dependencies:
|
|
|
143
142
|
- !ruby/object:Gem::Version
|
|
144
143
|
version: '0'
|
|
145
144
|
- !ruby/object:Gem::Dependency
|
|
145
|
+
name: benchmark-ips
|
|
146
146
|
requirement: !ruby/object:Gem::Requirement
|
|
147
147
|
requirements:
|
|
148
148
|
- - ">="
|
|
149
149
|
- !ruby/object:Gem::Version
|
|
150
150
|
version: '0'
|
|
151
|
-
name: benchmark-ips
|
|
152
151
|
type: :development
|
|
153
152
|
prerelease: false
|
|
154
153
|
version_requirements: !ruby/object:Gem::Requirement
|
|
@@ -177,6 +176,7 @@ files:
|
|
|
177
176
|
- lib/logstash/filters/dictionary/file.rb
|
|
178
177
|
- lib/logstash/filters/dictionary/json_file.rb
|
|
179
178
|
- lib/logstash/filters/dictionary/memory.rb
|
|
179
|
+
- lib/logstash/filters/dictionary/streaming_yaml_parser.rb
|
|
180
180
|
- lib/logstash/filters/dictionary/yaml_file.rb
|
|
181
181
|
- lib/logstash/filters/dictionary/yaml_visitor.rb
|
|
182
182
|
- lib/logstash/filters/fetch_strategy/file.rb
|
|
@@ -199,13 +199,13 @@ files:
|
|
|
199
199
|
- spec/fixtures/tag-omap-dict.yml
|
|
200
200
|
- spec/support/build_huge_dictionaries.rb
|
|
201
201
|
- spec/support/rspec_wait_handler_helper.rb
|
|
202
|
+
- version
|
|
202
203
|
homepage: http://www.elastic.co/guide/en/logstash/current/index.html
|
|
203
204
|
licenses:
|
|
204
205
|
- Apache License (2.0)
|
|
205
206
|
metadata:
|
|
206
207
|
logstash_plugin: 'true'
|
|
207
208
|
logstash_group: filter
|
|
208
|
-
post_install_message:
|
|
209
209
|
rdoc_options: []
|
|
210
210
|
require_paths:
|
|
211
211
|
- lib
|
|
@@ -220,8 +220,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
220
220
|
- !ruby/object:Gem::Version
|
|
221
221
|
version: '0'
|
|
222
222
|
requirements: []
|
|
223
|
-
rubygems_version: 3.
|
|
224
|
-
signing_key:
|
|
223
|
+
rubygems_version: 3.7.2
|
|
225
224
|
specification_version: 4
|
|
226
225
|
summary: Replaces field contents based on a hash or YAML file
|
|
227
226
|
test_files:
|