fluent-plugin-opensearch 1.1.7 → 1.1.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 59ef255b7e5cb5ceae9b97b0678cdc7151f0514263934f21e1028a45220180d6
4
- data.tar.gz: 7603936854d8a36c74b6fa6ee4187b089cd781c997da8b97eed21fafbd52833c
3
+ metadata.gz: '04843ec3381adb39f0e7fbc2a6cc76c25fa8d1d85d5733e500e58d7814beb57a'
4
+ data.tar.gz: b5e60e70521053e723b9c4df9953b03774a1207f7cf3c19b9c3aeb385e03f15f
5
5
  SHA512:
6
- metadata.gz: 4caf0aa8ff2803a46536db82defff7b203f837dea3e2fc97b46487a04c9d85c5b4f71b9145985e9616e4db54f4bdef93a61ca85d941612474794276bdbf37531
7
- data.tar.gz: b9fb24c499f3c84ece032e744a567ee9c34b7b8a2f47c39a706408e80fb7c8f19624499d447ed4191e75333c4050ef79237e51037dd0122ea95fc7aa6ad6f097
6
+ metadata.gz: 80cb6808fef85cc16fb835324f2ff3fbe3d42978d3c3f72dccf35251d39b3fe29f74d1271c30b2913959fd7cdc1f4e277ed91608663756e24d3d2e9b438e2943
7
+ data.tar.gz: 6d936e5608366a827cdd03f8bf51615b34780f9fdb7354a764cb2f39e4c570ad5f768ccc73cf286c9f22a3bafc529868b7e4434c8ea6b001af3328c541a7cb3e
@@ -8,7 +8,7 @@ jobs:
8
8
  steps:
9
9
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
10
10
  - name: Set up Ruby 3.0
11
- uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
11
+ uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
12
12
  with:
13
13
  ruby-version: '3.1'
14
14
  - name: Build and test with Rake
@@ -26,7 +26,7 @@ jobs:
26
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
27
27
  steps:
28
28
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
- - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
29
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
30
30
  with:
31
31
  ruby-version: ${{ matrix.ruby }}
32
32
  - name: unit testing
@@ -26,7 +26,7 @@ jobs:
26
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
27
27
  steps:
28
28
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
- - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
29
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
30
30
  with:
31
31
  ruby-version: ${{ matrix.ruby }}
32
32
  - name: unit testing
@@ -26,7 +26,7 @@ jobs:
26
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
27
27
  steps:
28
28
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
- - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
29
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
30
30
  with:
31
31
  ruby-version: ${{ matrix.ruby }}
32
32
  - name: unit testing
data/History.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ### [Unreleased]
4
4
 
5
+ ### 1.1.8
6
+ - `out_opensearch`: limit what a host placeholder can expand to (#192)
7
+ - `out_opensearch`: escape a `customize_template` value inside a string (#194)
8
+ - `out_opensearch`: lock the client cache against flush threads (#195)
9
+
5
10
  ### 1.1.7
6
11
  - test: remove duplicate keys from bulk response fixtures (#190)
7
12
  - out\_opensearch: keep AWS credential providers refreshable to fix expired SigV4 tokens with assume\_role\_arn (#188)
data/README.md CHANGED
@@ -1241,6 +1241,12 @@ records = {key1: "value1", key2: "value2"}
1241
1241
  </match>
1242
1242
  ```
1243
1243
 
1244
+ #### host and hosts
1245
+
1246
+ `host` and `hosts` take placeholders too, for example `hosts https://logs-${tag}.example.com:9201/os`. A value may only fill in the part of a host name that the placeholder stands for, and how much it may fill in depends on the names that you write after the placeholder. `logs-${tag}.example.com` lets a value add a sub domain of `example.com`, such as `nginx.access`. `logs-${tag}.com` lets a value fill in one name only, because a single name fixes the top level domain and nothing else: the name that decides who owns the domain would still come from the value. A value that does not fit raises `UnrecoverableRequestFailure`.
1247
+
1248
+ **Note**: the configured `user`, `password` and `custom_headers` are sent to whichever host the value picks. So write a domain that you control, where nobody else can create a sub domain. A shared domain such as `amazonaws.com` is not enough. Without such a domain, let only senders that you trust set the placeholder value.
1249
+
1244
1250
  ## Multi workers
1245
1251
 
1246
1252
  Since Fluentd v0.14, multi workers feature had been implemented to increase throughput with multiple processes. This feature allows Fluentd processes to use one or more CPUs. This feature will be enabled by the following system configuration:
@@ -3,7 +3,7 @@ $:.push File.expand_path('../lib', __FILE__)
3
3
 
4
4
  Gem::Specification.new do |s|
5
5
  s.name = 'fluent-plugin-opensearch'
6
- s.version = '1.1.7'
6
+ s.version = '1.1.8'
7
7
  s.authors = ['Hiroshi Hatake']
8
8
  s.email = ['cosmo0920.wp@gmail.com']
9
9
  s.description = %q{Opensearch output plugin for Fluent event collector}
@@ -51,7 +51,9 @@ module Fluent::Plugin
51
51
  config_param :password, :string, :default => nil, :secret => true
52
52
  config_param :path, :string, :default => nil
53
53
  config_param :scheme, :enum, :list => [:https, :http], :default => :http
54
- config_param :hosts, :string, :default => nil
54
+ # `hosts` accepts "https://user:password@host", so it is masked in the
55
+ # configuration dump like `password` is.
56
+ config_param :hosts, :string, :default => nil, :secret => true
55
57
  config_param :index_name, :string, :default => "fluentd"
56
58
  config_param :parse_timestamp, :bool, :default => false
57
59
  config_param :timestamp_key_format, :string, :default => nil
@@ -24,6 +24,8 @@
24
24
  # specific language governing permissions and limitations
25
25
  # under the License.
26
26
 
27
+ require 'json'
28
+
27
29
  require 'fluent/error'
28
30
  require_relative './opensearch_error'
29
31
 
@@ -42,11 +44,25 @@ module Fluent::OpenSearchIndexTemplate
42
44
  end
43
45
  file_contents = IO.read(template_file).gsub(/\n/,'')
44
46
  customize_template.each do |key, value|
45
- file_contents = file_contents.gsub(key,value.downcase)
47
+ file_contents = substitute_template_value(file_contents, key, value.downcase)
46
48
  end
47
49
  JSON.parse(file_contents, allow_duplicate_key: true)
48
50
  end
49
51
 
52
+ # A value can come from a tag or a record field. If its key is inside a JSON
53
+ # string ($1), a `"` in the value would end that string, and the rest would
54
+ # be read as extra template keys, so the value is escaped there. If the key
55
+ # is anywhere else, the value is a piece of JSON, like a whole settings
56
+ # object, so it goes in as it is. The block form stops gsub from reading
57
+ # backslash sequences in the value.
58
+ def substitute_template_value(file_contents, key, value)
59
+ json_string = /"(?:\\.|[^"\\])*"/
60
+ file_contents.gsub(/(#{json_string})|#{Regexp.escape(key)}/) do
61
+ # to_json puts the value in quotes, so drop them: a"b => a\"b
62
+ $1 ? $1.gsub(key) { value.to_json[1..-2] } : value
63
+ end
64
+ end
65
+
50
66
  def template_exists?(name, host = nil)
51
67
  if @use_legacy_template
52
68
  client(host).indices.get_template(:name => name)
@@ -95,13 +95,26 @@ module Fluent::Plugin
95
95
  DEFAULT_TARGET_BULK_BYTES = -1
96
96
  DEFAULT_POLICY_ID = "logstash-policy"
97
97
 
98
+ # A placeholder in `host`/`hosts` is expanded from the tag or a record
99
+ # field, so a log sender chooses its value. Without a check the value
100
+ # could add a host with ",", userinfo with "@" or a path with "/", and
101
+ # the configured user/password and custom_headers would go to that host.
102
+ HOST_PLACEHOLDER_PATTERN = /\$\{[^}]*\}/
103
+ HOST_LABEL_PATTERN = /\A[0-9A-Za-z_\-]+\z/
104
+ HOST_NAME_PATTERN = /\A[0-9A-Za-z_\-]+(?:\.[0-9A-Za-z_\-]+)*\z/
105
+ FIXED_SUFFIX_PATTERN = /\A[0-9A-Za-z_\-]*(?:\.[0-9A-Za-z_\-]+){2,}\.?\z/
106
+ # A host name or an IPv6 address in "[]", and the port that may follow it.
107
+ HOST_AND_PORT_PATTERN = /\A(\[[^\]]+\]|[^:]*)(?::([0-9]+))?\z/
108
+
98
109
  config_param :host, :string, :default => 'localhost'
99
110
  config_param :port, :integer, :default => 9200
100
111
  config_param :user, :string, :default => nil
101
112
  config_param :password, :string, :default => nil, :secret => true
102
113
  config_param :path, :string, :default => nil
103
114
  config_param :scheme, :enum, :list => [:https, :http], :default => :http
104
- config_param :hosts, :string, :default => nil
115
+ # `hosts` accepts "https://user:password@host" and "%{user}:%{password}@",
116
+ # so it is masked in the configuration dump like `password` is.
117
+ config_param :hosts, :string, :default => nil, :secret => true
105
118
  config_param :target_index_key, :string, :default => nil
106
119
  config_param :time_key_format, :string, :default => nil
107
120
  config_param :time_precision, :integer, :default => 9
@@ -185,7 +198,8 @@ module Fluent::Plugin
185
198
 
186
199
  config_section :endpoint, multi: false do
187
200
  config_param :region, :string
188
- config_param :url do |c|
201
+ # The URL can carry a user and a password, so mask it in the dump.
202
+ config_param :url, secret: true do |c|
189
203
  c.chomp("/")
190
204
  end
191
205
  config_param :access_key_id, :string, :default => ""
@@ -211,6 +225,8 @@ module Fluent::Plugin
211
225
 
212
226
  def initialize
213
227
  super
228
+ # `configure` already builds clients, so this cannot wait until then.
229
+ @client_mutex = Mutex.new
214
230
  end
215
231
 
216
232
  ######################################################################################################
@@ -316,6 +332,8 @@ module Fluent::Plugin
316
332
  log.info "host placeholder and template installation makes your OpenSearch cluster a bit slow down(beta)."
317
333
  end
318
334
 
335
+ warn_host_placeholder_with_credentials
336
+
319
337
  @template_names = []
320
338
  if !dry_run?
321
339
  if @template_name && @template_file
@@ -347,7 +365,6 @@ module Fluent::Plugin
347
365
  end
348
366
  end
349
367
  # Credential providers refresh themselves near expiration. This timer recreates them forcibly.
350
- @credential_mutex = Mutex.new
351
368
  if @endpoint
352
369
  @_aws_credentials = aws_credentials(@endpoint)
353
370
 
@@ -355,12 +372,14 @@ module Fluent::Plugin
355
372
  timer_execute(:out_opensearch_expire_credentials, @endpoint.refresh_credentials_interval) do
356
373
  log.debug('Recreate the AWS credentials')
357
374
 
358
- @credential_mutex.synchronize do
359
- @_os = nil
360
- begin
361
- @_aws_credentials = aws_credentials(@endpoint)
362
- rescue => e
363
- log.error("Failed to get new AWS credentials: #{e}")
375
+ begin
376
+ credentials = aws_credentials(@endpoint)
377
+ rescue => e
378
+ log.error("Failed to get new AWS credentials: #{e}")
379
+ else
380
+ @client_mutex.synchronize do
381
+ @_os = nil
382
+ @_aws_credentials = credentials
364
383
  end
365
384
  end
366
385
  end
@@ -481,6 +500,34 @@ module Fluent::Plugin
481
500
  end
482
501
  end
483
502
 
503
+ # A placeholder decides the host, so every credential the configuration
504
+ # holds follows it. `endpoint` is left alone because
505
+ # `get_connection_options` ignores the expanded host there.
506
+ def warn_host_placeholder_with_credentials
507
+ return if @endpoint
508
+
509
+ elements = (@hosts || @host).split(',')
510
+ return unless elements.any? { |element| element.match?(HOST_PLACEHOLDER_PATTERN) }
511
+
512
+ param_name = @hosts ? 'hosts' : 'host'
513
+ if @user || @password || !@custom_headers.empty?
514
+ log.warn "'#{param_name}' uses a placeholder, so 'user', 'password' and 'custom_headers' are sent to whichever host a tag or a record field expands to. Make sure that only trusted senders can set them."
515
+ end
516
+ if elements.any? { |element| credentials_before_placeholder?(element) }
517
+ # Do not put the element in the log: it might hold the password.
518
+ log.warn "'#{param_name}' has a user and password in front of a host that a placeholder decides, so they are sent to whichever host a tag or a record field expands to. Make sure that only trusted senders can set it."
519
+ end
520
+ end
521
+
522
+ # A user and password written in a URL sit in front of the host, so a
523
+ # placeholder that comes after them picks where they are sent.
524
+ def credentials_before_placeholder?(element)
525
+ userinfo = element.index('@')
526
+ placeholder = element.index('${')
527
+
528
+ !userinfo.nil? && !placeholder.nil? && userinfo < placeholder
529
+ end
530
+
484
531
  def placeholder?(name, param)
485
532
  placeholder_validities = []
486
533
  placeholder_validators(name, param).each do |v|
@@ -622,6 +669,14 @@ module Fluent::Plugin
622
669
  end
623
670
 
624
671
  def client(host = nil, compress_connection = false)
672
+ # Flush threads share @_os, and each of them may ask for a different
673
+ # host. Without this lock a thread can pass the check below and then
674
+ # pick up the client another thread just built for its own host, which
675
+ # sends the chunk to the wrong cluster.
676
+ @client_mutex.synchronize { build_or_reuse_client(host, compress_connection) }
677
+ end
678
+
679
+ def build_or_reuse_client(host, compress_connection)
625
680
  # check here to see if we already have a client connection for the given host
626
681
  connection_options = get_connection_options(host)
627
682
 
@@ -847,6 +902,63 @@ module Fluent::Plugin
847
902
  return logstash_prefix, logstash_dateformat, index_name, template_name, customize_template, application_name, pipeline
848
903
  end
849
904
 
905
+ def expand_host_placeholders(chunk)
906
+ template = @hosts || @host
907
+ template.split(',').each do |element|
908
+ offset = 0
909
+ while (match = HOST_PLACEHOLDER_PATTERN.match(element, offset))
910
+ offset = match.end(0)
911
+ value = extract_placeholders(match[0], chunk)
912
+ next if valid_host_placeholder_value?(value, match.pre_match, match.post_match)
913
+
914
+ raise UnrecoverableRequestFailure, "Rejected #{value.dump} for #{match[0]} in 'host'/'hosts': a placeholder may only fill in the part of a host name that it stands for."
915
+ end
916
+ end
917
+
918
+ extract_placeholders(template, chunk)
919
+ end
920
+
921
+ # What a value may hold depends on where the operator put the placeholder.
922
+ # `before` and `after` are the parts of the element around it.
923
+ def valid_host_placeholder_value?(value, before, after)
924
+ # The operator wrote the "[]" that an IPv6 address needs.
925
+ return Resolv::IPv6::Regex.match?(value) if before.end_with?('[') && after.start_with?(']')
926
+
927
+ unless whole_host_placeholder?(before, after)
928
+ # The value continues a name the operator wrote, so a port cannot
929
+ # follow it and a "." may only add a sub domain to a fixed domain.
930
+ pattern = fixed_domain_suffix?(after) ? HOST_NAME_PATTERN : HOST_LABEL_PATTERN
931
+ return pattern.match?(value)
932
+ end
933
+
934
+ host, port = value.match(HOST_AND_PORT_PATTERN)&.captures
935
+ # The value may end with a port, unless the operator wrote one already.
936
+ return false if host.nil? || (port && after.start_with?(':'))
937
+
938
+ HOST_NAME_PATTERN.match?(host) || bracketed_ipv6?(host, before)
939
+ end
940
+
941
+ # The value fills in the whole host name when only a scheme or a userinfo
942
+ # comes before it and the host name ends where the value ends.
943
+ def whole_host_placeholder?(before, after)
944
+ (before.empty? || before.end_with?('//', '@')) &&
945
+ (after.empty? || after.start_with?(':', '/', '?', '#'))
946
+ end
947
+
948
+ # The domain is fixed when two or more names follow the last placeholder,
949
+ # because the last labels of the host name then come from the operator.
950
+ # One name, such as ".com", lets a value with a "." pick any name under it.
951
+ def fixed_domain_suffix?(after)
952
+ host_part = after[/\A[^:\/?#]*/]
953
+ FIXED_SUFFIX_PATTERN.match?(host_part.split(HOST_PLACEHOLDER_PATTERN, -1).last.to_s)
954
+ end
955
+
956
+ # `URI()` in `get_connection_options` needs a scheme to read an IPv6
957
+ # address, and the operator writes the scheme, not the value.
958
+ def bracketed_ipv6?(host, before)
959
+ !before.empty? && host.start_with?('[') && Resolv::IPv6::Regex.match?(host[1..-2])
960
+ end
961
+
850
962
  def multi_workers_ready?
851
963
  true
852
964
  end
@@ -869,11 +981,7 @@ module Fluent::Plugin
869
981
  tag = chunk.metadata.tag
870
982
  chunk_id = dump_unique_id_hex(chunk.unique_id)
871
983
  extracted_values = expand_placeholders(chunk)
872
- host = if @hosts
873
- extract_placeholders(@hosts, chunk)
874
- else
875
- extract_placeholders(@host, chunk)
876
- end
984
+ host = expand_host_placeholders(chunk)
877
985
 
878
986
  affinity_target_indices = get_affinity_target_indices(chunk)
879
987
  chunk.msgpack_each do |time, record|
@@ -1137,8 +1245,14 @@ module Fluent::Plugin
1137
1245
 
1138
1246
  log.warn "Exception ignored in tag #{tag}: #{e.class.name} #{e.message}" if ignore
1139
1247
 
1140
- @_os = nil if @reconnect_on_error
1141
- @_os_info = nil if @reconnect_on_error
1248
+ if @reconnect_on_error
1249
+ # Another flush thread may be building a client right now, so drop the
1250
+ # cached one under the lock that guards it.
1251
+ @client_mutex.synchronize do
1252
+ @_os = nil
1253
+ @_os_info = nil
1254
+ end
1255
+ end
1142
1256
 
1143
1257
  raise UnrecoverableRequestFailure if ignore && @exception_backup
1144
1258
 
@@ -155,11 +155,7 @@ module Fluent::Plugin
155
155
  data_stream_template_name = @data_stream_template_name
156
156
  host = nil
157
157
  if @use_placeholder
158
- host = if @hosts
159
- extract_placeholders(@hosts, chunk)
160
- else
161
- extract_placeholders(@host, chunk)
162
- end
158
+ host = expand_host_placeholders(chunk)
163
159
  data_stream_name = extract_placeholders(@data_stream_name, chunk).downcase
164
160
  data_stream_template_name = extract_placeholders(@data_stream_template_name, chunk).downcase
165
161
  begin
@@ -322,6 +322,21 @@ class OpenSearchInputTest < Test::Unit::TestCase
322
322
  assert_equal 'raw.opensearch', instance.tag
323
323
  end
324
324
 
325
+ def test_hosts_is_masked_in_the_configuration_dump
326
+ config = Fluent::Config::Element.new(
327
+ 'ROOT', '', {
328
+ '@type' => 'opensearch',
329
+ 'hosts' => 'https://john:passw0rd@host1:443/elastic/',
330
+ 'tag' => 'raw.opensearch',
331
+ 'check_connection' => 'false',
332
+ }, [])
333
+ driver(config)
334
+
335
+ dump = config.to_masked_element.to_s
336
+ assert_false dump.include?('passw0rd')
337
+ assert_true dump.include?('hosts xxxxxx')
338
+ end
339
+
325
340
  def test_hosts_list_with_escape_placeholders
326
341
  config = %{
327
342
  hosts https://%{j+hn}:%{passw@rd}@host1:443/elastic/,http://host2
@@ -106,6 +106,32 @@ class OpenSearchOutputTest < Test::Unit::TestCase
106
106
  end
107
107
  end
108
108
 
109
+ def driver_with_captured_credential_refresh
110
+ config = Fluent::Config::Element.new(
111
+ 'ROOT', '', {
112
+ '@type' => 'opensearch',
113
+ }, [
114
+ Fluent::Config::Element.new('endpoint', '', {
115
+ 'url' => "https://search-opensearch.aws.example.com/",
116
+ 'region' => "local",
117
+ 'access_key_id' => 'YOUR_AWESOME_KEY',
118
+ 'secret_access_key' => 'YOUR_AWESOME_SECRET',
119
+ 'refresh_credentials_interval' => '18000',
120
+ }, []),
121
+ Fluent::Config::Element.new('buffer', 'tag', {}, [])
122
+ ])
123
+ d = Fluent::Test::Driver::Output.new(Fluent::Plugin::OpenSearchOutput)
124
+ timers = {}
125
+ d.instance.define_singleton_method(:timer_execute) do |title, interval, &block|
126
+ timers[title] = block
127
+ end
128
+ # Keep `configure` off the network.
129
+ d.instance.define_singleton_method(:detect_os_major_version) { 1 }
130
+ d.configure(config)
131
+
132
+ [d, timers[:out_opensearch_expire_credentials]]
133
+ end
134
+
109
135
  def stub_opensearch_info(url="http://localhost:9200/", version="1.2.2")
110
136
  body ="{\"version\":{\"number\":\"#{version}\", \"distribution\":\"opensearch\"},\"tagline\":\"The OpenSearch Project: https://opensearch.org/\"}"
111
137
  stub_request(:get, url).to_return({:status => 200, :body => body, :headers => { 'Content-Type' => 'json' } })
@@ -340,6 +366,41 @@ class OpenSearchOutputTest < Test::Unit::TestCase
340
366
  assert_equal :es, instance.endpoint.aws_service_name
341
367
  end
342
368
 
369
+ test 'hosts is masked in the configuration dump' do
370
+ config = Fluent::Config::Element.new(
371
+ 'ROOT', '', {
372
+ '@type' => 'opensearch',
373
+ 'hosts' => 'https://john:passw0rd@host1:443/elastic/',
374
+ }, [
375
+ Fluent::Config::Element.new('buffer', 'tag', {}, [])
376
+ ])
377
+ driver(config)
378
+
379
+ dump = config.to_masked_element.to_s
380
+ assert_false dump.include?('passw0rd')
381
+ assert_true dump.include?('hosts xxxxxx')
382
+ end
383
+
384
+ test 'endpoint url is masked in the configuration dump' do
385
+ config = Fluent::Config::Element.new(
386
+ 'ROOT', '', {
387
+ '@type' => 'opensearch',
388
+ }, [
389
+ Fluent::Config::Element.new('endpoint', '', {
390
+ 'url' => "https://john:passw0rd@search-opensearch.aws.example.com/",
391
+ 'region' => "local",
392
+ 'access_key_id' => 'YOUR_AWESOME_KEY',
393
+ 'secret_access_key' => 'YOUR_AWESOME_SECRET',
394
+ }, []),
395
+ Fluent::Config::Element.new('buffer', 'tag', {}, [])
396
+ ])
397
+ driver(config)
398
+
399
+ dump = config.to_masked_element.to_s
400
+ assert_false dump.include?('passw0rd')
401
+ assert_true dump.include?('url xxxxxx')
402
+ end
403
+
343
404
  test 'aws_credentials returns credential provider with access key' do
344
405
  config = Fluent::Config::Element.new(
345
406
  'ROOT', '', {
@@ -1107,6 +1168,53 @@ class OpenSearchOutputTest < Test::Unit::TestCase
1107
1168
  assert_requested(:put, "https://logs.google.com:777/os//#{endpoint}/myapp_alias_template-test.template", times: 1)
1108
1169
  end
1109
1170
 
1171
+ # A customize_template value can come from a tag or a record field.
1172
+ INJECTED_TEMPLATE_VALUE = 'x": {}}, "index_patterns": ["*"], "order": 2147483647, "aliases": {"y'
1173
+
1174
+ def custom_template(template_file_name, appid)
1175
+ template_file = File.join(File.dirname(__FILE__), template_file_name)
1176
+ driver.instance.get_custom_template(template_file,
1177
+ {"--appid--" => appid, "--index_prefix--" => "mylogs"})
1178
+ end
1179
+
1180
+ data("legacy_template" => ['test_alias_template.json',
1181
+ %w(aliases mappings order settings template)],
1182
+ "new_template" => ['test_index_alias_template.json',
1183
+ %w(index_patterns priority template)])
1184
+ def test_custom_template_value_cannot_add_template_keys(data)
1185
+ template_file_name, expected_keys = data
1186
+
1187
+ template = custom_template(template_file_name, INJECTED_TEMPLATE_VALUE)
1188
+
1189
+ assert_equal(expected_keys, template.keys.sort)
1190
+ end
1191
+
1192
+ def test_custom_template_value_stays_a_single_string
1193
+ template = custom_template('test_alias_template.json', INJECTED_TEMPLATE_VALUE)
1194
+
1195
+ assert_equal(5, template["order"])
1196
+ assert_equal(["#{INJECTED_TEMPLATE_VALUE}-alias"], template["aliases"].keys)
1197
+ end
1198
+
1199
+ def test_custom_template_value_keeps_a_backslash
1200
+ template = custom_template('test_alias_template.json', 'a\0b')
1201
+
1202
+ assert_equal(['a\0b-alias'], template["aliases"].keys)
1203
+ end
1204
+
1205
+ # A key is not always inside a string. If JSON is expected there, the value
1206
+ # is that piece of JSON.
1207
+ def test_custom_template_value_outside_a_string_stays_json
1208
+ template_file = File.join(File.dirname(__FILE__), 'test_settings_template.json')
1209
+
1210
+ template = driver.instance.get_custom_template(template_file,
1211
+ {"--settings--" => '{"number_of_shards": 5}',
1212
+ "--index_prefix--" => "mylogs"})
1213
+
1214
+ assert_equal({"number_of_shards" => 5}, template["settings"])
1215
+ assert_equal("mylogs*", template["template"])
1216
+ end
1217
+
1110
1218
  data("legacy_template" => [true, "_template"],
1111
1219
  "new_template" => [false, "_index_template"])
1112
1220
  def test_custom_template_installation_for_host_placeholder(data)
@@ -1892,6 +2000,43 @@ class OpenSearchOutputTest < Test::Unit::TestCase
1892
2000
  end
1893
2001
  end
1894
2002
 
2003
+ # Flush threads share @_os, so only one of them may look at it at a time.
2004
+ def test_client_does_not_let_flush_threads_overlap
2005
+ instance = driver.instance
2006
+ counter = Mutex.new
2007
+ inside = 0
2008
+ max_inside = 0
2009
+
2010
+ instance.define_singleton_method(:get_connection_options) do |con_host = nil|
2011
+ counter.synchronize do
2012
+ inside += 1
2013
+ max_inside = inside if inside > max_inside
2014
+ end
2015
+ sleep 0.05
2016
+ counter.synchronize { inside -= 1 }
2017
+ {hosts: [{host: con_host, port: 9200, scheme: 'http'}]}
2018
+ end
2019
+
2020
+ threads = 3.times.map { |i| Thread.new { instance.client("host-#{i}") } }
2021
+ threads.each(&:join)
2022
+
2023
+ assert_equal(1, max_inside)
2024
+ end
2025
+
2026
+ def test_client_returns_a_client_for_the_host_it_was_asked_for
2027
+ instance = driver.instance
2028
+
2029
+ results = 3.times.map { |i|
2030
+ Thread.new do
2031
+ 5.times.map { instance.client("host-#{i}:9201").transport.transport.hosts.first[:host] }
2032
+ end
2033
+ }.map(&:value)
2034
+
2035
+ results.each_with_index do |hosts, i|
2036
+ assert_equal(["host-#{i}"], hosts.uniq)
2037
+ end
2038
+ end
2039
+
1895
2040
  def test_password_is_required_if_specify_user
1896
2041
  config = %{
1897
2042
  user john
@@ -2876,6 +3021,149 @@ class OpenSearchOutputTest < Test::Unit::TestCase
2876
3021
  connection_spec = {host: "myhost-1", port: 9200, scheme: "http"}
2877
3022
  assert_equal("could not push logs to OpenSearch cluster (#{connection_spec.inspect}): [503] ", exception.message)
2878
3023
  end
3024
+
3025
+ # `extract_placeholders` needs a real chunk, so replace it with the values
3026
+ # that a crafted tag or record field would expand to. A bare value is the
3027
+ # one for "${pipeline_id}".
3028
+ def expand_hosts_with(hosts, placeholder_values)
3029
+ values = placeholder_values.is_a?(Hash) ? placeholder_values : {"pipeline_id" => placeholder_values}
3030
+ instance = driver.configure("hosts #{hosts}").instance
3031
+ instance.define_singleton_method(:extract_placeholders) do |value, _chunk|
3032
+ values.inject(value) { |expanded, (name, replacement)| expanded.gsub("${#{name}}", replacement) }
3033
+ end
3034
+ instance.expand_host_placeholders(nil)
3035
+ end
3036
+
3037
+ data("host name" => ["${pipeline_id},logs2.example.com:9201", "os.internal"],
3038
+ "with port" => ["${pipeline_id},logs2.example.com:9201", "os.internal:9200"],
3039
+ "in a url" => ["https://${pipeline_id}/os", "logs.example.com:9201"],
3040
+ "ipv6" => ["http://${pipeline_id}:9201", "[2001:db8::1]"],
3041
+ "ipv6 + port" => ["http://${pipeline_id}/os", "[2001:db8::1]:9201"],
3042
+ "ipv6 in []" => ["http://[${pipeline_id}]:9201", "2001:db8::1"])
3043
+ def test_accepts_placeholder_value_which_fills_in_the_whole_host(data)
3044
+ hosts, placeholder_value = data
3045
+ assert_equal(hosts.sub('${pipeline_id}', placeholder_value),
3046
+ expand_hosts_with(hosts, placeholder_value))
3047
+ end
3048
+
3049
+ data("extra host" => "evil.example.com,myhost",
3050
+ "userinfo" => "1@evil.example.com",
3051
+ "path" => "1/evil.example.com",
3052
+ "space" => "1 evil.example.com",
3053
+ "other domain" => "evil.example.net",
3054
+ "port" => "1:9999",
3055
+ "empty" => "")
3056
+ def test_rejects_placeholder_value_which_does_not_fit_its_place(placeholder_value)
3057
+ assert_raise(Fluent::Plugin::OpenSearchOutput::UnrecoverableRequestFailure) {
3058
+ expand_hosts_with("myhost-${pipeline_id},logs2.example.com:9201", placeholder_value)
3059
+ }
3060
+ end
3061
+
3062
+ # The operator wrote ".example.com", so a tag such as "nginx.access" only
3063
+ # adds a sub domain to it.
3064
+ def test_accepts_placeholder_value_with_a_sub_domain
3065
+ assert_equal("https://logs-nginx.access.example.com/os",
3066
+ expand_hosts_with("https://logs-${pipeline_id}.example.com/os", "nginx.access"))
3067
+ end
3068
+
3069
+ # ".example.com" is fixed even though another placeholder stands before it.
3070
+ def test_accepts_placeholder_value_with_a_sub_domain_before_another_placeholder
3071
+ assert_equal("https://nginx.access.us.example.com/os",
3072
+ expand_hosts_with("https://${pipeline_id}.${region}.example.com/os",
3073
+ {"pipeline_id" => "nginx.access", "region" => "us"}))
3074
+ end
3075
+
3076
+ # A trailing "." or a single name after the placeholder fixes no domain.
3077
+ data("trailing dot" => ["myhost-${pipeline_id}.", "evil.example.com"],
3078
+ "one name" => ["https://logs-${pipeline_id}.com/os", "evil.attacker"])
3079
+ def test_rejects_placeholder_value_which_leaves_the_configured_domain(data)
3080
+ hosts, placeholder_value = data
3081
+ assert_raise(Fluent::Plugin::OpenSearchOutput::UnrecoverableRequestFailure) {
3082
+ expand_hosts_with(hosts, placeholder_value)
3083
+ }
3084
+ end
3085
+
3086
+ data("extra host" => "evil.example.com,myhost",
3087
+ "port" => "1:9999",
3088
+ "empty" => "")
3089
+ def test_rejects_placeholder_value_which_is_not_a_sub_domain(placeholder_value)
3090
+ assert_raise(Fluent::Plugin::OpenSearchOutput::UnrecoverableRequestFailure) {
3091
+ expand_hosts_with("https://logs-${pipeline_id}.example.com/os", placeholder_value)
3092
+ }
3093
+ end
3094
+
3095
+ # `get_connection_options` reads an element with `URI()`, which cannot
3096
+ # read an IPv6 address when the element has no scheme.
3097
+ data("empty" => "",
3098
+ "port only" => ":9999",
3099
+ "two ports" => "os.internal:9200:9201",
3100
+ "path" => "myhost/evil.example.com",
3101
+ "ipv6" => "[2001:db8::1]")
3102
+ def test_rejects_placeholder_value_which_is_not_a_host_name(placeholder_value)
3103
+ assert_raise(Fluent::Plugin::OpenSearchOutput::UnrecoverableRequestFailure) {
3104
+ expand_hosts_with("${pipeline_id},logs2.example.com:9201", placeholder_value)
3105
+ }
3106
+ end
3107
+
3108
+ data("host name" => "os.internal:9200",
3109
+ "ipv6" => "[2001:db8::1]:9200")
3110
+ def test_rejects_placeholder_value_with_a_port_when_the_port_is_configured(placeholder_value)
3111
+ assert_raise(Fluent::Plugin::OpenSearchOutput::UnrecoverableRequestFailure) {
3112
+ expand_hosts_with("http://${pipeline_id}:9201", placeholder_value)
3113
+ }
3114
+ end
3115
+
3116
+ data("in []" => ["http://[${pipeline_id}]:9201", "2001:db8"],
3117
+ "extra host" => ["http://[${pipeline_id}]:9201", "[2001:db8::1],[2001:db8::2]"],
3118
+ "whole host" => ["http://${pipeline_id}:9201", "[2001:db8]"])
3119
+ def test_rejects_placeholder_value_which_is_not_an_ipv6_address(data)
3120
+ hosts, placeholder_value = data
3121
+ assert_raise(Fluent::Plugin::OpenSearchOutput::UnrecoverableRequestFailure) {
3122
+ expand_hosts_with(hosts, placeholder_value)
3123
+ }
3124
+ end
3125
+
3126
+ # The operator writes the structure, so only the placeholder value is
3127
+ # checked. Every form that `get_connection_options` accepts keeps working.
3128
+ data("plain host" => ["myhost-${pipeline_id},logs2.example.com:9201",
3129
+ "myhost-1,logs2.example.com:9201"],
3130
+ "url" => ["https://logs-${pipeline_id}.example.com:9201/os",
3131
+ "https://logs-1.example.com:9201/os"],
3132
+ "userinfo" => ["https://john:pass@logs-${pipeline_id}.example.com/os",
3133
+ "https://john:pass@logs-1.example.com/os"],
3134
+ "ipv6" => ["http://[2001:db8::1]:9201,myhost-${pipeline_id}",
3135
+ "http://[2001:db8::1]:9201,myhost-1"])
3136
+ def test_keeps_the_configured_host_structure(data)
3137
+ hosts, expected = data
3138
+ assert_equal(expected, expand_hosts_with(hosts, "1"))
3139
+ end
3140
+
3141
+ def test_warns_when_placeholder_host_is_used_with_credentials
3142
+ d = driver(%{
3143
+ host logs-${tag}.example.com
3144
+ user john
3145
+ password doe
3146
+ @log_level info
3147
+ })
3148
+ assert_true(d.logs.any? { |log| log.include?("'host' uses a placeholder") })
3149
+ end
3150
+
3151
+ def test_warns_when_hosts_embeds_credentials_in_front_of_a_placeholder
3152
+ d = driver(%{
3153
+ hosts https://john:secret@logs-${tag}.example.com/os
3154
+ @log_level info
3155
+ })
3156
+ assert_true(d.logs.any? { |log| log.include?("in front of a host that a placeholder decides") })
3157
+ assert_false(d.logs.any? { |log| log.include?("secret") })
3158
+ end
3159
+
3160
+ def test_does_not_warn_when_credentials_belong_to_a_static_host
3161
+ d = driver(%{
3162
+ hosts https://john:secret@static.example.com/os,logs-${tag}.example.com
3163
+ @log_level info
3164
+ })
3165
+ assert_false(d.logs.any? { |log| log.include?("in front of a host that a placeholder decides") })
3166
+ end
2879
3167
  end
2880
3168
 
2881
3169
  def test_writes_to_logstash_index_with_specified_prefix_uppercase
@@ -4117,4 +4405,43 @@ class OpenSearchOutputTest < Test::Unit::TestCase
4117
4405
  d.run
4118
4406
  assert { d.logs.any?(/\[error\]: Failed to get new AWS credentials: No valid AWS credentials found.\n/) }
4119
4407
  end
4408
+
4409
+ test 'a failed credential refresh keeps the current credentials and client' do
4410
+ d, refresh = driver_with_captured_credential_refresh
4411
+ instance = d.instance
4412
+ credentials = instance.instance_variable_get(:@_aws_credentials)
4413
+ instance.instance_variable_set(:@_os, :cached_client)
4414
+ flexmock(instance).should_receive(:aws_credentials)
4415
+ .and_raise(::RuntimeError.new("No valid AWS credentials found."))
4416
+
4417
+ refresh.call
4418
+
4419
+ assert_same credentials, instance.instance_variable_get(:@_aws_credentials)
4420
+ assert_equal :cached_client, instance.instance_variable_get(:@_os)
4421
+ assert { d.logs.any?(/Failed to get new AWS credentials: No valid AWS credentials found./) }
4422
+ end
4423
+
4424
+ # The refresh shares @_os with the flush threads, so it has to use their lock
4425
+ # instead of one of its own.
4426
+ test 'a credential refresh swaps the credentials under the client lock' do
4427
+ d, refresh = driver_with_captured_credential_refresh
4428
+ instance = d.instance
4429
+ new_credentials = Aws::Credentials.new('NEW_KEY', 'NEW_SECRET')
4430
+ flexmock(instance).should_receive(:aws_credentials).and_return(new_credentials)
4431
+ instance.instance_variable_set(:@_os, :cached_client)
4432
+
4433
+ # Records what the plugin had done by the time it left the lock.
4434
+ seen = []
4435
+ fake_lock = Object.new
4436
+ fake_lock.define_singleton_method(:synchronize) do |&block|
4437
+ block.call
4438
+ seen << [instance.instance_variable_get(:@_aws_credentials),
4439
+ instance.instance_variable_get(:@_os)]
4440
+ end
4441
+ instance.instance_variable_set(:@client_mutex, fake_lock)
4442
+
4443
+ refresh.call
4444
+
4445
+ assert_equal [[new_credentials, nil]], seen
4446
+ end
4120
4447
  end
@@ -0,0 +1,4 @@
1
+ {
2
+ "template": "--index_prefix--*",
3
+ "settings": --settings--
4
+ }
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluent-plugin-opensearch
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.1.7
4
+ version: 1.1.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Hiroshi Hatake
@@ -249,6 +249,7 @@ files:
249
249
  - test/plugin/test_opensearch_tls.rb
250
250
  - test/plugin/test_out_opensearch.rb
251
251
  - test/plugin/test_out_opensearch_data_stream.rb
252
+ - test/plugin/test_settings_template.json
252
253
  - test/plugin/test_template.json
253
254
  - test/test_log-ext.rb
254
255
  homepage: https://github.com/fluent/fluent-plugin-opensearch
@@ -270,7 +271,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
270
271
  - !ruby/object:Gem::Version
271
272
  version: '0'
272
273
  requirements: []
273
- rubygems_version: 4.0.18
274
+ rubygems_version: 3.6.9
274
275
  specification_version: 4
275
276
  summary: Opensearch output plugin for Fluent event collector
276
277
  test_files:
@@ -287,5 +288,6 @@ test_files:
287
288
  - test/plugin/test_opensearch_tls.rb
288
289
  - test/plugin/test_out_opensearch.rb
289
290
  - test/plugin/test_out_opensearch_data_stream.rb
291
+ - test/plugin/test_settings_template.json
290
292
  - test/plugin/test_template.json
291
293
  - test/test_log-ext.rb