fluent-plugin-opensearch 1.1.6 → 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: 26499682224f8d7dc4a9b204f99172b512057fb5c9b67d03b0bf1637d39f4224
4
- data.tar.gz: b6bc5ff55815d050f50a9c8658e98e0dbfce5511ade14342fa26d31c4dba59d0
3
+ metadata.gz: '04843ec3381adb39f0e7fbc2a6cc76c25fa8d1d85d5733e500e58d7814beb57a'
4
+ data.tar.gz: b5e60e70521053e723b9c4df9953b03774a1207f7cf3c19b9c3aeb385e03f15f
5
5
  SHA512:
6
- metadata.gz: a0ffa39d8a11088809008e5d74997d1765ca624153890bf0699ea2ab9f5522d80b2226427291bee42b04abf370636abd8a7dc5d8a13af1baf6cc4089e345da91
7
- data.tar.gz: eb8983700b0a7af66010a5e8ec5f2f5ad5a30532fd877c7ab547aabfef015b45b012b83069c9775bb0965ebb95e01da8b75a8eb303d481ec768d076673679a3f
6
+ metadata.gz: 80cb6808fef85cc16fb835324f2ff3fbe3d42978d3c3f72dccf35251d39b3fe29f74d1271c30b2913959fd7cdc1f4e277ed91608663756e24d3d2e9b438e2943
7
+ data.tar.gz: 6d936e5608366a827cdd03f8bf51615b34780f9fdb7354a764cb2f39e4c570ad5f768ccc73cf286c9f22a3bafc529868b7e4434c8ea6b001af3328c541a7cb3e
@@ -4,3 +4,20 @@ updates:
4
4
  directory: '/'
5
5
  schedule:
6
6
  interval: 'monthly'
7
+ groups:
8
+ # PR: "Security update [package] from [old] to [new]"
9
+ # This PR should be merged in hurry
10
+ security-updates:
11
+ applies-to: security-updates
12
+ patterns:
13
+ - '*'
14
+
15
+ # PR: "Bump [package] from [old] to [new]"
16
+ # No need to be merged this PR in hurry. It is enough to merge
17
+ # once in a month.
18
+ monthly-updates:
19
+ applies-to: version-updates
20
+ patterns:
21
+ - '*'
22
+ # Allow to create PR both of security and normal updates.
23
+ open-pull-requests-limit: 1
@@ -12,12 +12,12 @@ jobs:
12
12
  steps:
13
13
  - name: Generate token
14
14
  id: generate_token
15
- uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
15
+ uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
16
16
  with:
17
17
  app-id: ${{ secrets.PROJECT_APP_ID }}
18
18
  private-key: ${{ secrets.PROJECT_APP_PRIVATE_KEY }}
19
19
  owner: fluent
20
- - uses: actions/add-to-project@244f685bbc3b7adfa8466e08b698b5577571133e # v1.0.2
20
+ - uses: actions/add-to-project@5afcf98fcd03f1c2f92c3c83f58ae24323cc57fd # v2.0.0
21
21
  with:
22
22
  project-url: https://github.com/orgs/fluent/projects/4
23
23
  github-token: ${{ steps.generate_token.outputs.token }}
@@ -6,9 +6,9 @@ jobs:
6
6
  coverage:
7
7
  runs-on: ubuntu-latest
8
8
  steps:
9
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
9
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
10
10
  - name: Set up Ruby 3.0
11
- uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.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
@@ -16,6 +16,6 @@ jobs:
16
16
  bundle install
17
17
  bundle exec rake
18
18
  - name: Coveralls GitHub Action
19
- uses: coverallsapp/github-action@648a8eb78e6d50909eff900e4ec85cab4524a45b # v2.3.6
19
+ uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8
20
20
  with:
21
21
  github-token: ${{ secrets.GITHUB_TOKEN }}
@@ -25,8 +25,8 @@ jobs:
25
25
  - ubuntu-latest
26
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
27
27
  steps:
28
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
29
- - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
30
30
  with:
31
31
  ruby-version: ${{ matrix.ruby }}
32
32
  - name: unit testing
@@ -25,8 +25,8 @@ jobs:
25
25
  - macOS-latest
26
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
27
27
  steps:
28
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
29
- - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
+ - uses: ruby/setup-ruby@14594264cd68ce8a2345dd349bc3d138a4ef85c8 # v1.327.0
30
30
  with:
31
31
  ruby-version: ${{ matrix.ruby }}
32
32
  - name: unit testing
@@ -25,8 +25,8 @@ jobs:
25
25
  - windows-latest
26
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
27
27
  steps:
28
- - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
29
- - uses: ruby/setup-ruby@c4e5b1316158f92e3d49443a9d58b31d25ac0f8f # v1.306.0
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
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,18 @@
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
+
10
+ ### 1.1.7
11
+ - test: remove duplicate keys from bulk response fixtures (#190)
12
+ - out\_opensearch: keep AWS credential providers refreshable to fix expired SigV4 tokens with assume\_role\_arn (#188)
13
+ - docs: document OpenSearch Serverless startup version-check workaround (#186)
14
+ - Allow duplicate keys in JSON parser (#185)
15
+ - Replace yajl-ruby with the json gem for JSON handling (#183)
16
+
5
17
  ### 1.1.6
6
18
  - out\_opensearch\_data\_stream: filter failed items in data stream bulk error log (#175)
7
19
  - out\_opensearch: fix default value of `refresh_credentials_interval` (#159)
data/README.md CHANGED
@@ -902,7 +902,7 @@ compression_level best_compression
902
902
 
903
903
  ### prefer_oj_serializer
904
904
 
905
- With default behavior, OpenSearch client uses `Yajl` as JSON encoder/decoder.
905
+ With default behavior, OpenSearch client uses `JSON` as JSON encoder/decoder.
906
906
  `Oj` is the alternative high performance JSON encoder/decoder.
907
907
  When this parameter sets as `true`, OpenSearch client uses `Oj` as JSON encoder/decoder.
908
908
 
@@ -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:
@@ -1568,7 +1574,7 @@ In this case, the endpoint configuration looks like:
1568
1574
 
1569
1575
  ### Expiring AWS credentials
1570
1576
 
1571
- If you want to expire AWS credentials in certain interval, you should specify `refresh_credentials_interval` parameter under `endpoint` section:
1577
+ Credentials obtained from STS, IMDS or ECS are refreshed by the AWS SDK before they expire, so you usually do not need to change this parameter. If you want to recreate the credential provider forcibly in a certain interval, you should specify `refresh_credentials_interval` parameter under `endpoint` section:
1572
1578
 
1573
1579
  ```aconf
1574
1580
  <endpoint>
@@ -1581,7 +1587,7 @@ If you want to expire AWS credentials in certain interval, you should specify `r
1581
1587
 
1582
1588
  ### Use OpenSearch Serverless
1583
1589
 
1584
- If you want to use Serverless version of OpenSearch service, you have to specify `aoss` in `aws_service_name` under `endpoint` section:
1590
+ If you want to use the Serverless version of OpenSearch service, you have to specify `aoss` in `aws_service_name` under the `endpoint` section:
1585
1591
 
1586
1592
  ```aconf
1587
1593
  <endpoint>
@@ -1592,6 +1598,39 @@ If you want to use Serverless version of OpenSearch service, you have to specify
1592
1598
  </endpoint>
1593
1599
  ```
1594
1600
 
1601
+ **NOTE:** OpenSearch Serverless only supports a subset of the OpenSearch APIs and does **not** serve the root/info endpoint (`GET /`) that this plugin uses to detect the OpenSearch major version at startup. If you leave the default `verify_os_version_at_startup true`, the plugin fails the version probe with `404`, keeps retrying, and never starts sending data:
1602
+
1603
+ ```
1604
+ Could not communicate to OpenSearch, resetting connection and trying again. [404]
1605
+ ```
1606
+
1607
+ To avoid this, disable the startup version check and pin the version (Serverless is OpenSearch 2.x compatible):
1608
+
1609
+ ```aconf
1610
+ <match es.**>
1611
+ @type opensearch
1612
+ logstash_format true
1613
+ include_tag_key true
1614
+ flush_interval 1s
1615
+
1616
+ # Serverless does not serve GET /, so skip the version probe.
1617
+ verify_os_version_at_startup false
1618
+ default_opensearch_version 2
1619
+
1620
+ <endpoint>
1621
+ url https://CLUSTER_ENDPOINT_URL
1622
+ region us-east-2
1623
+ aws_service_name aoss
1624
+ </endpoint>
1625
+ </match>
1626
+ ```
1627
+
1628
+ **Other Serverless limitations to keep in mind:**
1629
+
1630
+ - Your IAM identity needs a **data access policy** on the collection (at least `aoss:CreateIndex` and `aoss:WriteDocument`). An IAM permission policy alone is not sufficient.
1631
+ - For `TIMESERIES` collections, custom document IDs are not supported. Do not set `id_key`, and keep the default `write_operation index`, since `create` / `update` / `upsert` require an `_id`.
1632
+ - Index templates, rollover/ILM, and data streams rely on APIs that Serverless does not expose, so template-related options and `@type opensearch_data_stream` are not usable against a Serverless collection.
1633
+
1595
1634
  ## Troubleshooting
1596
1635
 
1597
1636
  See [Troubleshooting document](README.Troubleshooting.md)
@@ -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.6'
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
@@ -374,7 +376,7 @@ module Fluent::Plugin
374
376
  def run_slice(slice_id=nil)
375
377
  slice_query = @base_query
376
378
  slice_query = slice_query.merge('slice' => { 'id' => slice_id, 'max' => @num_slices}) unless slice_id.nil?
377
- result = client.search(@options.merge(:body => Yajl.dump(slice_query) ))
379
+ result = client.search(@options.merge(:body => JSON.generate(slice_query) ))
378
380
  es = Fluent::MultiEventStream.new
379
381
 
380
382
  result["hits"]["hits"].each {|hit| process_events(hit, es)}
@@ -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
 
@@ -33,7 +35,7 @@ module Fluent::OpenSearchIndexTemplate
33
35
  raise "If you specify a template_name you must specify a valid template file (checked '#{template_file}')!"
34
36
  end
35
37
  file_contents = IO.read(template_file).gsub(/\n/,'')
36
- JSON.parse(file_contents)
38
+ JSON.parse(file_contents, allow_duplicate_key: true)
37
39
  end
38
40
 
39
41
  def get_custom_template(template_file, customize_template)
@@ -42,9 +44,23 @@ 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)
48
+ end
49
+ JSON.parse(file_contents, allow_duplicate_key: true)
50
+ end
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
46
63
  end
47
- JSON.parse(file_contents)
48
64
  end
49
65
 
50
66
  def template_exists?(name, host = nil)
@@ -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
  ######################################################################################################
@@ -225,13 +241,13 @@ module Fluent::Plugin
225
241
  if conf[:assume_role_arn].nil?
226
242
  aws_container_credentials_relative_uri = conf[:ecs_container_credentials_relative_uri] || ENV["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"]
227
243
  if aws_container_credentials_relative_uri.nil?
228
- credentials = Aws::SharedCredentials.new({retries: 2}).credentials rescue nil
229
- credentials ||= Aws::InstanceProfileCredentials.new.credentials rescue nil
230
- credentials ||= Aws::ECSCredentials.new.credentials
244
+ credentials = usable_credentials_provider { Aws::SharedCredentials.new }
245
+ credentials ||= usable_credentials_provider { Aws::InstanceProfileCredentials.new }
246
+ credentials ||= usable_credentials_provider { Aws::ECSCredentials.new }
231
247
  else
232
248
  credentials = Aws::ECSCredentials.new({
233
249
  credential_path: aws_container_credentials_relative_uri
234
- }).credentials
250
+ })
235
251
  end
236
252
  else
237
253
  if conf[:assume_role_web_identity_token_file].nil?
@@ -239,21 +255,28 @@ module Fluent::Plugin
239
255
  role_arn: conf[:assume_role_arn],
240
256
  role_session_name: conf[:assume_role_session_name],
241
257
  region: sts_creds_region(conf)
242
- }).credentials
258
+ })
243
259
  else
244
260
  credentials = Aws::AssumeRoleWebIdentityCredentials.new({
245
261
  role_arn: conf[:assume_role_arn],
246
262
  web_identity_token_file: conf[:assume_role_web_identity_token_file],
247
263
  region: sts_creds_region(conf)
248
- }).credentials
264
+ })
249
265
  end
250
266
  end
251
267
  end
252
- raise "No valid AWS credentials found." unless credentials.set?
268
+ raise "No valid AWS credentials found." unless credentials&.credentials&.set?
253
269
 
254
270
  credentials
255
271
  end
256
272
 
273
+ def usable_credentials_provider
274
+ provider = yield
275
+ provider if provider.credentials&.set?
276
+ rescue
277
+ nil
278
+ end
279
+
257
280
  def sts_creds_region(conf)
258
281
  conf[:sts_credentials_region] || conf[:region]
259
282
  end
@@ -309,6 +332,8 @@ module Fluent::Plugin
309
332
  log.info "host placeholder and template installation makes your OpenSearch cluster a bit slow down(beta)."
310
333
  end
311
334
 
335
+ warn_host_placeholder_with_credentials
336
+
312
337
  @template_names = []
313
338
  if !dry_run?
314
339
  if @template_name && @template_file
@@ -339,8 +364,7 @@ module Fluent::Plugin
339
364
  }
340
365
  end
341
366
  end
342
- # If AWS credentials is set, consider to expire credentials information forcibly before expired.
343
- @credential_mutex = Mutex.new
367
+ # Credential providers refresh themselves near expiration. This timer recreates them forcibly.
344
368
  if @endpoint
345
369
  @_aws_credentials = aws_credentials(@endpoint)
346
370
 
@@ -348,12 +372,14 @@ module Fluent::Plugin
348
372
  timer_execute(:out_opensearch_expire_credentials, @endpoint.refresh_credentials_interval) do
349
373
  log.debug('Recreate the AWS credentials')
350
374
 
351
- @credential_mutex.synchronize do
352
- @_os = nil
353
- begin
354
- @_aws_credentials = aws_credentials(@endpoint)
355
- rescue => e
356
- 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
357
383
  end
358
384
  end
359
385
  end
@@ -369,7 +395,7 @@ module Fluent::Plugin
369
395
  OpenSearch::API.settings[:serializer] = Fluent::Plugin::Serializer::Oj
370
396
  end
371
397
  rescue LoadError
372
- @dump_proc = Yajl.method(:dump)
398
+ @dump_proc = JSON.method(:generate)
373
399
  end
374
400
 
375
401
  raise Fluent::ConfigError, "`password` must be present if `user` is present" if @user && @password.nil?
@@ -474,6 +500,34 @@ module Fluent::Plugin
474
500
  end
475
501
  end
476
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
+
477
531
  def placeholder?(name, param)
478
532
  placeholder_validities = []
479
533
  placeholder_validators(name, param).each do |v|
@@ -615,6 +669,14 @@ module Fluent::Plugin
615
669
  end
616
670
 
617
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)
618
680
  # check here to see if we already have a client connection for the given host
619
681
  connection_options = get_connection_options(host)
620
682
 
@@ -630,7 +692,7 @@ module Fluent::Plugin
630
692
  :aws_sigv4,
631
693
  service: @endpoint.aws_service_name.to_s,
632
694
  region: @endpoint.region,
633
- credentials: @_aws_credentials,
695
+ credentials_provider: @_aws_credentials,
634
696
  )
635
697
 
636
698
  f.adapter @http_backend, @backend_options
@@ -840,6 +902,63 @@ module Fluent::Plugin
840
902
  return logstash_prefix, logstash_dateformat, index_name, template_name, customize_template, application_name, pipeline
841
903
  end
842
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
+
843
962
  def multi_workers_ready?
844
963
  true
845
964
  end
@@ -862,11 +981,7 @@ module Fluent::Plugin
862
981
  tag = chunk.metadata.tag
863
982
  chunk_id = dump_unique_id_hex(chunk.unique_id)
864
983
  extracted_values = expand_placeholders(chunk)
865
- host = if @hosts
866
- extract_placeholders(@hosts, chunk)
867
- else
868
- extract_placeholders(@host, chunk)
869
- end
984
+ host = expand_host_placeholders(chunk)
870
985
 
871
986
  affinity_target_indices = get_affinity_target_indices(chunk)
872
987
  chunk.msgpack_each do |time, record|
@@ -940,7 +1055,7 @@ module Fluent::Plugin
940
1055
  {"_index" => {"order" => "desc"}}
941
1056
  ]
942
1057
  }
943
- result = client.search(options.merge(:body => Yajl.dump(query)))
1058
+ result = client.search(options.merge(:body => JSON.generate(query)))
944
1059
  # There should be just one hit per _id, but in case there still is multiple, just the oldest index is stored to map
945
1060
  result['hits']['hits'].each do |hit|
946
1061
  indices[hit["_id"]] = hit["_index"]
@@ -1130,8 +1245,14 @@ module Fluent::Plugin
1130
1245
 
1131
1246
  log.warn "Exception ignored in tag #{tag}: #{e.class.name} #{e.message}" if ignore
1132
1247
 
1133
- @_os = nil if @reconnect_on_error
1134
- @_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
1135
1256
 
1136
1257
  raise UnrecoverableRequestFailure if ignore && @exception_backup
1137
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
@@ -596,7 +596,6 @@ class TestOpenSearchErrorHandler < Test::Unit::TestCase
596
596
  "_type" : "bar",
597
597
  "_id" : "6",
598
598
  "status" : 500,
599
- "_type" : "bar",
600
599
  "error" : {
601
600
  "type" : "out_of_memory_error",
602
601
  "reason":"Java heap space"
@@ -85,6 +85,53 @@ class OpenSearchOutputTest < Test::Unit::TestCase
85
85
  }
86
86
  end
87
87
 
88
+ def chained_credentials_conf
89
+ {
90
+ access_key_id: '',
91
+ secret_access_key: '',
92
+ assume_role_arn: nil,
93
+ ecs_container_credentials_relative_uri: nil,
94
+ }
95
+ end
96
+
97
+ def with_stubbed_aws_responses
98
+ saved = Aws.config.key?(:stub_responses) ? Aws.config[:stub_responses] : :__unset__
99
+ Aws.config[:stub_responses] = true
100
+ yield
101
+ ensure
102
+ if saved == :__unset__
103
+ Aws.config.delete(:stub_responses)
104
+ else
105
+ Aws.config[:stub_responses] = saved
106
+ end
107
+ end
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
+
88
135
  def stub_opensearch_info(url="http://localhost:9200/", version="1.2.2")
89
136
  body ="{\"version\":{\"number\":\"#{version}\", \"distribution\":\"opensearch\"},\"tagline\":\"The OpenSearch Project: https://opensearch.org/\"}"
90
137
  stub_request(:get, url).to_return({:status => 200, :body => body, :headers => { 'Content-Type' => 'json' } })
@@ -315,9 +362,141 @@ class OpenSearchOutputTest < Test::Unit::TestCase
315
362
  assert_equal "fluentd", instance.endpoint.assume_role_session_name
316
363
  assert_nil instance.endpoint.assume_role_web_identity_token_file
317
364
  assert_nil instance.endpoint.sts_credentials_region
365
+ assert_equal 18000, instance.endpoint.refresh_credentials_interval
318
366
  assert_equal :es, instance.endpoint.aws_service_name
319
367
  end
320
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
+
404
+ test 'aws_credentials returns credential provider with access key' do
405
+ config = Fluent::Config::Element.new(
406
+ 'ROOT', '', {
407
+ '@type' => 'opensearch',
408
+ }, [
409
+ Fluent::Config::Element.new('endpoint', '', {
410
+ 'url' => "https://search-opensearch.aws.example.com/",
411
+ 'region' => "local",
412
+ 'access_key_id' => 'YOUR_AWESOME_KEY',
413
+ 'secret_access_key' => 'YOUR_AWESOME_SECRET',
414
+ }, []),
415
+ Fluent::Config::Element.new('buffer', 'tag', {}, [])
416
+
417
+ ])
418
+ instance = driver(config).instance
419
+ credentials = instance.aws_credentials(instance.endpoint)
420
+
421
+ assert_instance_of Aws::Credentials, credentials
422
+ assert_equal 'YOUR_AWESOME_KEY', credentials.credentials.access_key_id
423
+ end
424
+
425
+ test 'aws_credentials returns refreshable credential provider with assume_role_arn' do
426
+ with_stubbed_aws_responses do
427
+ config = Fluent::Config::Element.new(
428
+ 'ROOT', '', {
429
+ '@type' => 'opensearch',
430
+ }, [
431
+ Fluent::Config::Element.new('endpoint', '', {
432
+ 'url' => "https://search-opensearch.aws.example.com/",
433
+ 'region' => "local",
434
+ 'assume_role_arn' => 'arn:aws:iam::123456789012:role/some-role',
435
+ }, []),
436
+ Fluent::Config::Element.new('buffer', 'tag', {}, [])
437
+
438
+ ])
439
+ instance = driver(config).instance
440
+ credentials = instance.aws_credentials(instance.endpoint)
441
+
442
+ assert_instance_of Aws::AssumeRoleCredentials, credentials
443
+ assert_kind_of Aws::RefreshingCredentials, credentials
444
+ assert_true credentials.credentials.set?
445
+ end
446
+ end
447
+
448
+ test 'aws_credentials falls back to the next provider when a provider has no credentials' do
449
+ unset_provider = flexmock('unset provider', credentials: Aws::Credentials.new(nil, nil))
450
+ ecs_provider = flexmock('ecs provider', credentials: Aws::Credentials.new('ECS_KEY', 'ECS_SECRET'))
451
+ flexmock(Aws::SharedCredentials).should_receive(:new).and_return(unset_provider)
452
+ flexmock(Aws::InstanceProfileCredentials).should_receive(:new).and_return(unset_provider)
453
+ flexmock(Aws::ECSCredentials).should_receive(:new).and_return(ecs_provider)
454
+ instance = driver.instance
455
+
456
+ assert_same ecs_provider, instance.aws_credentials(chained_credentials_conf)
457
+ end
458
+
459
+ test 'aws_credentials raises when no provider has usable credentials' do
460
+ unset_provider = flexmock('unset provider', credentials: Aws::Credentials.new(nil, nil))
461
+ flexmock(Aws::SharedCredentials).should_receive(:new).and_return(unset_provider)
462
+ flexmock(Aws::InstanceProfileCredentials).should_receive(:new).and_return(unset_provider)
463
+ flexmock(Aws::ECSCredentials).should_receive(:new)
464
+ .and_raise(ArgumentError.new("Cannot instantiate an ECS Credential Provider without a credential path or endpoint."))
465
+ instance = driver.instance
466
+
467
+ assert_raise(RuntimeError.new("No valid AWS credentials found.")) do
468
+ instance.aws_credentials(chained_credentials_conf)
469
+ end
470
+ end
471
+
472
+ test 'credentials provider is passed to aws_sigv4 middleware' do
473
+ with_stubbed_aws_responses do
474
+ config = Fluent::Config::Element.new(
475
+ 'ROOT', '', {
476
+ '@type' => 'opensearch',
477
+ }, [
478
+ Fluent::Config::Element.new('endpoint', '', {
479
+ 'url' => "https://search-opensearch.aws.example.com/",
480
+ 'region' => "local",
481
+ 'assume_role_arn' => 'arn:aws:iam::123456789012:role/some-role',
482
+ }, []),
483
+ Fluent::Config::Element.new('buffer', 'tag', {}, [])
484
+
485
+ ])
486
+ instance = driver(config).instance
487
+ # Reaching into Faraday internals. If this breaks after a Faraday or
488
+ # faraday_middleware-aws-sigv4 update, check RackBuilder::Handler.
489
+ handler = instance.client.transport.transport.connections.first.connection.builder.handlers.find { |h|
490
+ h.klass == FaradayMiddleware::AwsSigV4
491
+ }
492
+
493
+ assert_not_nil handler
494
+ signer = handler.build(proc {}).instance_variable_get(:@signer)
495
+ assert_instance_of Aws::AssumeRoleCredentials, signer.credentials_provider
496
+ assert_same instance.instance_variable_get(:@_aws_credentials), signer.credentials_provider
497
+ end
498
+ end
499
+
321
500
  data("OpenSearch Service" => [:es, 'es'],
322
501
  "OpenSearch Serverless" => [:aoss, 'aoss'])
323
502
  test 'configure endpoint section w/ aws_service_name' do |data|
@@ -989,6 +1168,53 @@ class OpenSearchOutputTest < Test::Unit::TestCase
989
1168
  assert_requested(:put, "https://logs.google.com:777/os//#{endpoint}/myapp_alias_template-test.template", times: 1)
990
1169
  end
991
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
+
992
1218
  data("legacy_template" => [true, "_template"],
993
1219
  "new_template" => [false, "_index_template"])
994
1220
  def test_custom_template_installation_for_host_placeholder(data)
@@ -1774,6 +2000,43 @@ class OpenSearchOutputTest < Test::Unit::TestCase
1774
2000
  end
1775
2001
  end
1776
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
+
1777
2040
  def test_password_is_required_if_specify_user
1778
2041
  config = %{
1779
2042
  user john
@@ -2758,6 +3021,149 @@ class OpenSearchOutputTest < Test::Unit::TestCase
2758
3021
  connection_spec = {host: "myhost-1", port: 9200, scheme: "http"}
2759
3022
  assert_equal("could not push logs to OpenSearch cluster (#{connection_spec.inspect}): [503] ", exception.message)
2760
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
2761
3167
  end
2762
3168
 
2763
3169
  def test_writes_to_logstash_index_with_specified_prefix_uppercase
@@ -3648,7 +4054,6 @@ class OpenSearchOutputTest < Test::Unit::TestCase
3648
4054
  "_index" : "foo",
3649
4055
  "_type" : "bar",
3650
4056
  "_id" : "abc",
3651
- "_id" : "abc",
3652
4057
  "status" : 409
3653
4058
  }
3654
4059
  }
@@ -4000,4 +4405,43 @@ class OpenSearchOutputTest < Test::Unit::TestCase
4000
4405
  d.run
4001
4406
  assert { d.logs.any?(/\[error\]: Failed to get new AWS credentials: No valid AWS credentials found.\n/) }
4002
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
4003
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.6
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.10
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