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 +4 -4
- data/.github/dependabot.yml +17 -0
- data/.github/workflows/add-to-project.yml +2 -2
- data/.github/workflows/coverage.yaml +3 -3
- data/.github/workflows/linux.yml +2 -2
- data/.github/workflows/macos.yml +2 -2
- data/.github/workflows/windows.yml +2 -2
- data/History.md +12 -0
- data/README.md +42 -3
- data/fluent-plugin-opensearch.gemspec +1 -1
- data/lib/fluent/plugin/in_opensearch.rb +4 -2
- data/lib/fluent/plugin/opensearch_index_template.rb +19 -3
- data/lib/fluent/plugin/out_opensearch.rb +148 -27
- data/lib/fluent/plugin/out_opensearch_data_stream.rb +1 -5
- data/test/plugin/test_in_opensearch.rb +15 -0
- data/test/plugin/test_opensearch_error_handler.rb +0 -1
- data/test/plugin/test_out_opensearch.rb +445 -1
- data/test/plugin/test_settings_template.json +4 -0
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: '04843ec3381adb39f0e7fbc2a6cc76c25fa8d1d85d5733e500e58d7814beb57a'
|
|
4
|
+
data.tar.gz: b5e60e70521053e723b9c4df9953b03774a1207f7cf3c19b9c3aeb385e03f15f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 80cb6808fef85cc16fb835324f2ff3fbe3d42978d3c3f72dccf35251d39b3fe29f74d1271c30b2913959fd7cdc1f4e277ed91608663756e24d3d2e9b438e2943
|
|
7
|
+
data.tar.gz: 6d936e5608366a827cdd03f8bf51615b34780f9fdb7354a764cb2f39e4c570ad5f768ccc73cf286c9f22a3bafc529868b7e4434c8ea6b001af3328c541a7cb3e
|
data/.github/dependabot.yml
CHANGED
|
@@ -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@
|
|
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@
|
|
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@
|
|
9
|
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
10
10
|
- name: Set up Ruby 3.0
|
|
11
|
-
uses: ruby/setup-ruby@
|
|
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@
|
|
19
|
+
uses: coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8
|
|
20
20
|
with:
|
|
21
21
|
github-token: ${{ secrets.GITHUB_TOKEN }}
|
data/.github/workflows/linux.yml
CHANGED
|
@@ -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@
|
|
29
|
-
- uses: ruby/setup-ruby@
|
|
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/.github/workflows/macos.yml
CHANGED
|
@@ -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@
|
|
29
|
-
- uses: ruby/setup-ruby@
|
|
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@
|
|
29
|
-
- uses: ruby/setup-ruby@
|
|
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 `
|
|
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
|
|
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
|
+
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
|
-
|
|
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 =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
229
|
-
credentials ||= Aws::InstanceProfileCredentials.new
|
|
230
|
-
credentials ||= Aws::ECSCredentials.new
|
|
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
|
-
})
|
|
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
|
-
})
|
|
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
|
-
})
|
|
264
|
+
})
|
|
249
265
|
end
|
|
250
266
|
end
|
|
251
267
|
end
|
|
252
|
-
raise "No valid AWS credentials found." unless credentials
|
|
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
|
-
#
|
|
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
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =>
|
|
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
|
-
|
|
1134
|
-
|
|
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 =
|
|
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
|
|
@@ -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
|
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.
|
|
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:
|
|
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
|