fluent-plugin-s3 1.8.4 → 1.8.6

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: e3de70d5f20b42bd86ce425d436b2af2fabcb12bcd11756d6ccb311d32f117e7
4
- data.tar.gz: 8896f6755b9c7cb6c726950493164bf4275cf0776b16cd37dd660ffe511e2f16
3
+ metadata.gz: d21a6392c01d12f1eb7d42afcb9a068157020c0627772895dedad47f1807c788
4
+ data.tar.gz: 22cef56213063ea0493c2b1599d2ff8019ab1f0da44e1c6598497100b695859f
5
5
  SHA512:
6
- metadata.gz: d9bd5499f054de826654d44208858684917d226f229fb4eb3e9f04cfe09dad089345fa16333ed05848499e4dd4b5ab321b36e7dc6dec733f0f2110f280b07512
7
- data.tar.gz: f21db8ac19f9a3e05502f39f0e32d3740a903431800df70871539404fb3fdb4226b0c974c0f65d2e5f7e32f177a78df90a8529314fc84a9c1de4d051bac577e0
6
+ metadata.gz: 9e516fbe5805ae366cbd0c588a64b45d8b4b80d0b4afa65e0ebc8eea2e59c1ea6f50d119493b2516f5db94db0b9d53b83a72022badf3179c624269a1493dafa9
7
+ data.tar.gz: 7b3f6921d9a6f88eb802b93d8613c0f706abb1162be56e833fe4e65b3084bc7a2c7c10296a05467127e6451fdb10ea66457bc5f4eb2bfe62fe965935d2822649
@@ -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
@@ -4,25 +4,34 @@ on:
4
4
  branches: [master]
5
5
  pull_request:
6
6
  branches: [master]
7
+ schedule:
8
+ - cron: '0 0 1 * *'
7
9
  jobs:
10
+ ruby-versions:
11
+ uses: ruby/actions/.github/workflows/ruby_versions.yml@master
12
+ with:
13
+ engine: cruby
14
+ min_version: 2.7
8
15
  build:
16
+ needs: ruby-versions
9
17
  runs-on: ${{ matrix.os }}
10
18
  strategy:
11
19
  fail-fast: false
12
20
  matrix:
13
- ruby: [ '4.0', '3.4', '3.3', '3.2', '3.1', '3.0', '2.7' ]
21
+ ruby: ${{ fromJson(needs.ruby-versions.outputs.versions) }}
22
+ exclude:
23
+ - ruby: head
14
24
  os:
15
25
  - ubuntu-latest
16
26
  name: Ruby ${{ matrix.ruby }} unit testing on ${{ matrix.os }}
17
27
  steps:
18
- - uses: actions/checkout@v6
19
- - uses: ruby/setup-ruby@v1
28
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
29
+ - uses: ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0
20
30
  with:
21
31
  ruby-version: ${{ matrix.ruby }}
22
32
  - name: unit testing
23
33
  env:
24
34
  CI: true
25
35
  run: |
26
- gem install rake
27
36
  bundle install --jobs 4 --retry 3
28
37
  bundle exec rake test
@@ -6,8 +6,11 @@ on:
6
6
  jobs:
7
7
  stale:
8
8
  runs-on: ubuntu-latest
9
+ permissions:
10
+ issues: write
11
+ pull-requests: write
9
12
  steps:
10
- - uses: actions/stale@v10
13
+ - uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
11
14
  with:
12
15
  repo-token: ${{ secrets.GITHUB_TOKEN }}
13
16
  days-before-stale: 30
data/ChangeLog CHANGED
@@ -1,3 +1,15 @@
1
+ Release 1.8.6 - 2026/09/04
2
+
3
+ * in_s3: stop retrying SQS messages that can never succeed (GitHub: #484)
4
+ * in_s3: allow duplicate keys in JSON parser (GitHub: #481)
5
+ * in_s3/out_s3: mask `proxy_uri` in the configuration dump (GitHub: #486)
6
+ * out_s3: build the chunk debug message only when debug is on (GitHub: #485)
7
+ * out_s3: fix parameter descriptions of `checksum_algorithm` and `s3_metadata` (GitHub: #487)
8
+
9
+ Release 1.8.5 - 2026/06/25
10
+
11
+ * in_s3: enforce size limits on decompressed payloads
12
+
1
13
  Release 1.8.4 - 2026/03/04
2
14
 
3
15
  * in_s3: add aws_profile / aws_credential_process parameters for credencials (GitHub: #464)
data/Gemfile CHANGED
@@ -1,3 +1,3 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  gemspec
data/Gemfile.v0.12 CHANGED
@@ -1,4 +1,4 @@
1
- source "http://rubygems.org"
1
+ source "https://rubygems.org"
2
2
 
3
3
  gem 'json', '= 1.8.3'
4
4
  gem 'fluentd', '~> 0.12.0'
data/README.md CHANGED
@@ -1,7 +1,6 @@
1
1
  # Amazon S3 plugin for [Fluentd](http://github.com/fluent/fluentd)
2
2
 
3
- [<img src="https://travis-ci.org/fluent/fluent-plugin-s3.svg?branch=master"
4
- alt="Build Status" />](https://travis-ci.org/fluent/fluent-plugin-s3) [<img
3
+ [![linux](https://github.com/fluent/fluent-plugin-s3/actions/workflows/linux.yml/badge.svg)](https://github.com/fluent/fluent-plugin-s3/actions/workflows/linux.yml) [<img
5
4
  src="https://codeclimate.com/github/fluent/fluent-plugin-s3/badges/gpa.svg"
6
5
  />](https://codeclimate.com/github/fluent/fluent-plugin-s3)
7
6
 
data/VERSION CHANGED
@@ -1 +1 @@
1
- 1.8.4
1
+ 1.8.6
data/docs/input.md CHANGED
@@ -29,6 +29,12 @@ See also [Configuration: credentials](credentials.md) for common comprehensive p
29
29
 
30
30
  Whether or not object metadata should be added to the record. Defaults to `false`. See below for details.
31
31
 
32
+ ## decompression_size_limit
33
+
34
+ The size limit of the decompressed data. The default is `256m` (256 MiB).
35
+ This parameter is designed to prevent memory exhaustion when extracting highly compressed objects from S3.
36
+ An object over the limit is never ingested and its SQS message is deleted, so raising the limit afterwards will not bring the notification back. See [Handling of failed messages](#handling-of-failed-messages).
37
+
32
38
  ## match_regexp
33
39
 
34
40
  If provided, process the S3 object only if its keys matches the regular expression
@@ -70,7 +76,7 @@ Check AWS key on start. Default is true.
70
76
 
71
77
  ## proxy_uri
72
78
 
73
- URI of proxy environment.
79
+ URI of proxy environment. The value is masked in the configuration dump, so credentials embedded in the URI are not written to the log.
74
80
 
75
81
  ## \<sqs\> section
76
82
 
@@ -104,4 +110,12 @@ Interval to retry polling SQS if polling unsuccessful, in seconds. Default is 30
104
110
 
105
111
  ### event_bridge_mode
106
112
  When true, Amazon S3 Event Notification should be configured using the EventBridge integration. Default is false.
107
- See [Configure S3 event notification using EventBridge](https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html) for additional information.
113
+ See [Configure S3 event notification using EventBridge](https://docs.aws.amazon.com/AmazonS3/latest/userguide/EventBridge.html) for additional information.
114
+
115
+ # Handling of failed messages
116
+
117
+ When handling an SQS message fails, the plugin either deletes the message or leaves it in the queue for redelivery.
118
+
119
+ A failure that cannot change on redelivery is reported with a warning and the message is deleted. This covers a body that is not an S3 notification, an object key that S3 would reject, an object that the configured `store_as` cannot extract, an object over `decompression_size_limit`, and a line that the configured parser refuses. The object in S3 is not touched, but it is never ingested and the notification is gone, so a mistake in `store_as`, `<parse>` or `decompression_size_limit` loses the notifications that arrive while it is in effect. The warning names the object key, truncated to 256 bytes, so that such an object can be ingested by hand once the configuration is corrected.
120
+
121
+ Every other failure, such as a networking error, a throttle, a missing object or a bug in the plugin, leaves the message in the queue and SQS redelivers it after the visibility timeout. Configure a [redrive policy with a dead letter queue](https://docs.aws.amazon.com/AWSSimpleQueueService/latest/SQSDeveloperGuide/sqs-dead-letter-queues.html) so that such a message is moved aside after a bounded number of attempts instead of being retried until the retention period expires.
data/docs/output.md CHANGED
@@ -209,7 +209,7 @@ parquet file page size. default: 8192 bytes
209
209
 
210
210
  ### parquet_row_group_size
211
211
 
212
- parquet file row group size. default: 128 MB
212
+ parquet file row group size. default: 128 MiB
213
213
 
214
214
  ### record_type
215
215
 
@@ -312,7 +312,7 @@ Check AWS key on start. Default is true.
312
312
 
313
313
  ## proxy_uri
314
314
 
315
- uri of proxy environment.
315
+ uri of proxy environment. The value is masked in the configuration dump, so credentials embedded in the URI are not written to the log.
316
316
 
317
317
  ## path
318
318
 
@@ -5,6 +5,8 @@ require 'aws-sdk-s3'
5
5
  require 'aws-sdk-sqs'
6
6
  require 'aws-sdk-sqs/queue_poller'
7
7
  require 'cgi/util'
8
+ require 'json'
9
+ require 'msgpack'
8
10
  require 'zlib'
9
11
  require 'time'
10
12
  require 'tempfile'
@@ -22,11 +24,24 @@ module Fluent::Plugin
22
24
  end
23
25
 
24
26
  DEFAULT_PARSE_TYPE = "none"
27
+ UNRECOVERABLE_ERRORS = [
28
+ Fluent::UnrecoverableError,
29
+ TypeError,
30
+ ArgumentError,
31
+ EncodingError,
32
+ JSON::ParserError,
33
+ MessagePack::UnpackError,
34
+ Zlib::Error,
35
+ ].freeze
36
+ DECOMPRESSION_SIZE_LIMIT = 256 * 1024 * 1024
37
+ LOG_MAX_BYTES = 256
25
38
 
26
39
  desc "Use aws-sdk-ruby bundled cert"
27
40
  config_param :use_bundled_cert, :bool, default: false
28
41
  desc "Add object metadata to the records parsed out of a given object"
29
42
  config_param :add_object_metadata, :bool, default: false
43
+ desc 'The size limit of the extracted element.'
44
+ config_param :decompression_size_limit, :size, default: DECOMPRESSION_SIZE_LIMIT
30
45
  desc "AWS access key id"
31
46
  config_param :aws_key_id, :string, default: nil, secret: true
32
47
  desc "AWS secret key."
@@ -94,7 +109,7 @@ module Fluent::Plugin
94
109
  desc "Check AWS key on start"
95
110
  config_param :check_apikey_on_start, :bool, default: true
96
111
  desc "URI of proxy environment"
97
- config_param :proxy_uri, :string, default: nil
112
+ config_param :proxy_uri, :string, default: nil, secret: true
98
113
  desc "Optional RegEx to match incoming messages"
99
114
  config_param :match_regexp, :regexp, default: nil
100
115
 
@@ -159,7 +174,7 @@ module Fluent::Plugin
159
174
 
160
175
  Aws.use_bundled_cert! if @use_bundled_cert
161
176
 
162
- @extractor = EXTRACTOR_REGISTRY.lookup(@store_as).new(log: log)
177
+ @extractor = EXTRACTOR_REGISTRY.lookup(@store_as).new(log: log, decompression_size_limit: @decompression_size_limit)
163
178
  @extractor.configure(conf)
164
179
 
165
180
  @parser = parser_create(conf: parser_config, default_type: DEFAULT_PARSE_TYPE)
@@ -210,17 +225,18 @@ module Fluent::Plugin
210
225
  begin
211
226
  @poller.poll(options) do |message|
212
227
  begin
213
- body = Yajl.load(message.body)
214
- log.debug(body)
215
- next unless is_valid_queue(body) # skip test queue
216
- if @match_regexp
217
- raw_key = get_raw_key(body)
218
- key = CGI.unescape(raw_key)
219
- next unless @match_regexp.match?(key)
220
- end
221
- process(body)
228
+ raw_key, key = object_key_from(message)
229
+ next if raw_key.nil?
230
+
231
+ process(raw_key, key)
232
+ rescue *UNRECOVERABLE_ERRORS => e
233
+ attributes = { message_id: message.message_id, error_class: e.class.to_s, error: e.message }
234
+ attributes[:key] = loggable(raw_key) if raw_key
235
+ log.warn("Skipping SQS message that cannot be handled", attributes)
236
+ log.warn_backtrace(e.backtrace)
237
+ next
222
238
  rescue => e
223
- log.warn(error: e)
239
+ log.warn("Failed to handle SQS message", message_id: message.message_id, error: e)
224
240
  log.warn_backtrace(e.backtrace)
225
241
  throw :skip_delete
226
242
  end
@@ -232,6 +248,36 @@ module Fluent::Plugin
232
248
  end
233
249
  end
234
250
 
251
+ def loggable(value)
252
+ value.byteslice(0, LOG_MAX_BYTES).scrub("?")
253
+ end
254
+
255
+ def object_key_from(message)
256
+ log.debug { "SQS message #{message.message_id}: #{message.body.dump}" }
257
+
258
+ body = JSON.parse(message.body, allow_duplicate_key: true)
259
+ unless body.is_a?(Hash)
260
+ log.warn("Skipping SQS message whose body is not a JSON object", message_id: message.message_id)
261
+ return nil
262
+ end
263
+
264
+ unless is_valid_queue(body)
265
+ log.warn("Skipping SQS message that is not an S3 notification", message_id: message.message_id)
266
+ return nil
267
+ end
268
+
269
+ raw_key = get_raw_key(body)
270
+ if raw_key.nil?
271
+ log.warn("Skipping SQS message with a malformed notification body", message_id: message.message_id)
272
+ return nil
273
+ end
274
+
275
+ key = CGI.unescape(raw_key)
276
+ return nil if @match_regexp && !@match_regexp.match?(key)
277
+
278
+ [raw_key, key]
279
+ end
280
+
235
281
  def is_valid_queue(body)
236
282
  if @sqs.event_bridge_mode
237
283
  log.debug("checking for eventbridge property")
@@ -244,9 +290,9 @@ module Fluent::Plugin
244
290
 
245
291
  def get_raw_key(body)
246
292
  if @sqs.event_bridge_mode
247
- body["detail"]["object"]["key"]
293
+ body.dig("detail", "object", "key")
248
294
  else
249
- body["Records"].first["s3"]["object"]["key"]
295
+ body.dig("Records", 0, "s3", "object", "key")
250
296
  end
251
297
  end
252
298
 
@@ -345,10 +391,7 @@ module Fluent::Plugin
345
391
  raise "can't call S3 API. Please check your credentials or s3_region configuration. error = #{e.inspect}"
346
392
  end
347
393
 
348
- def process(body)
349
- raw_key = get_raw_key(body)
350
- key = CGI.unescape(raw_key)
351
-
394
+ def process(raw_key, key)
352
395
  io = @bucket.object(key).get.body
353
396
  content = @extractor.extract(io)
354
397
  es = Fluent::MultiEventStream.new
@@ -365,13 +408,19 @@ module Fluent::Plugin
365
408
  end
366
409
 
367
410
  class Extractor
411
+ class SizeLimitError < Fluent::UnrecoverableError; end
412
+ class CommandError < Fluent::UnrecoverableError; end
413
+
368
414
  include Fluent::Configurable
369
415
 
370
416
  attr_reader :log
371
417
 
372
- def initialize(log: $log, **options)
418
+ BYTES_TO_READ = 64 * 1024
419
+
420
+ def initialize(log: $log, decompression_size_limit: DECOMPRESSION_SIZE_LIMIT, **options)
373
421
  super()
374
422
  @log = log
423
+ @decompression_size_limit = decompression_size_limit
375
424
  end
376
425
 
377
426
  def configure(conf)
@@ -399,6 +448,44 @@ module Fluent::Plugin
399
448
  raise Fluent::ConfigError, "'#{command}' utility must be in PATH for #{algo} compression"
400
449
  end
401
450
  end
451
+
452
+ def extract_with_command(command, io, tempfile_basename = "s3-extractor-tmp")
453
+ path = if io.respond_to?(:path)
454
+ io.path
455
+ else
456
+ extractor = TextExtractor.new(log: log, decompression_size_limit: @decompression_size_limit)
457
+ temp = Tempfile.new(tempfile_basename)
458
+ temp.write(extractor.extract(io))
459
+ temp.close
460
+ temp.path
461
+ end
462
+
463
+ out = ''
464
+ begin
465
+ Open3.popen3("#{command} #{path}") do |stdin, stdout, stderr, wait_thr|
466
+ stdin.close
467
+ while (chunk = stdout.read(BYTES_TO_READ))
468
+ out << chunk
469
+ if out.bytesize > @decompression_size_limit
470
+ Process.kill("TERM", wait_thr.pid) rescue nil
471
+ raise SizeLimitError, "Extracted data exceeds limit of #{@decompression_size_limit} bytes"
472
+ end
473
+ end
474
+
475
+ status = wait_thr.value
476
+ if status.success?
477
+ out
478
+ elsif status.signaled?
479
+ # not a CommandError: a signal comes from outside the data, so a retry may succeed
480
+ raise "Command #{command} was terminated by signal #{status.termsig}"
481
+ else
482
+ stderr_tail = stderr.read.strip[0, 200]
483
+ reason = stderr_tail.empty? ? "" : ": #{stderr_tail}"
484
+ raise CommandError, "Command #{command} exited with #{status.exitstatus}#{reason}"
485
+ end
486
+ end
487
+ end
488
+ end
402
489
  end
403
490
 
404
491
  class GzipExtractor < Extractor
@@ -414,19 +501,25 @@ module Fluent::Plugin
414
501
  # https://bugs.ruby-lang.org/issues/11180
415
502
  # https://github.com/exAspArk/multiple_files_gzip_reader
416
503
  def extract(io)
417
- parts = []
504
+ out = ''
418
505
  loop do
419
506
  unused = nil
420
507
  Zlib::GzipReader.wrap(io) do |gz|
421
- parts << gz.read
508
+ while (chunk = gz.read(BYTES_TO_READ))
509
+ out << chunk
510
+ if out.bytesize > @decompression_size_limit
511
+ raise SizeLimitError, "Extracted data exceeds limit of #{@decompression_size_limit} bytes"
512
+ end
513
+ end
422
514
  unused = gz.unused
423
515
  gz.finish
424
516
  end
425
517
  io.pos -= unused ? unused.length : 0
426
518
  break if io.eof?
427
519
  end
428
- io.close
429
- parts.join
520
+ out
521
+ ensure
522
+ io.close unless io.closed?
430
523
  end
431
524
  end
432
525
 
@@ -440,7 +533,14 @@ module Fluent::Plugin
440
533
  end
441
534
 
442
535
  def extract(io)
443
- io.read
536
+ out = ''
537
+ while (chunk = io.read(BYTES_TO_READ))
538
+ out << chunk
539
+ if out.bytesize > @decompression_size_limit
540
+ raise SizeLimitError, "Extracted data exceeds limit of #{@decompression_size_limit} bytes"
541
+ end
542
+ end
543
+ out
444
544
  end
445
545
  end
446
546
 
@@ -121,7 +121,7 @@ module Fluent::Plugin
121
121
  desc "Check AWS key on start"
122
122
  config_param :check_apikey_on_start, :bool, default: true
123
123
  desc "URI of proxy environment"
124
- config_param :proxy_uri, :string, default: nil
124
+ config_param :proxy_uri, :string, default: nil, secret: true
125
125
  desc "Use S3 reduced redundancy storage for 33% cheaper pricing. Deprecated. Use storage_class instead"
126
126
  config_param :reduced_redundancy, :bool, default: false, deprecated: "Use storage_class parameter instead."
127
127
  desc "The type of storage to use for the object(STANDARD,REDUCED_REDUNDANCY,STANDARD_IA)"
@@ -162,9 +162,9 @@ module Fluent::Plugin
162
162
  config_param :warn_for_delay, :time, default: nil
163
163
  desc "Arbitrary S3 tag-set for the object"
164
164
  config_param :tagging, :string, default: nil
165
- desc "Arbitrary S3 metadata headers to set for the object"
166
- config_param :checksum_algorithm, :string, default: nil
167
165
  desc "Indicates the algorithm you want Amazon S3 to use to create the checksum for the object (CRC32,CRC32C,SHA1,SHA256)"
166
+ config_param :checksum_algorithm, :string, default: nil
167
+ desc "Arbitrary S3 metadata headers to set for the object"
168
168
  config_param :s3_metadata, :hash, default: nil
169
169
  config_section :bucket_lifecycle_rule, param_name: :bucket_lifecycle_rules, multi: true do
170
170
  desc "A unique ID for this rule"
@@ -362,7 +362,7 @@ module Fluent::Plugin
362
362
  begin
363
363
  @compressor.compress(chunk, tmp)
364
364
  tmp.rewind
365
- log.debug "out_s3: write chunk #{dump_unique_id_hex(chunk.unique_id)} with metadata #{chunk.metadata} to s3://#{@s3_bucket}/#{s3path}"
365
+ log.debug { "out_s3: write chunk #{dump_unique_id_hex(chunk.unique_id)} with metadata #{chunk.metadata} to s3://#{@s3_bucket}/#{s3path}" }
366
366
 
367
367
  put_options = {
368
368
  body: tmp,
@@ -390,7 +390,7 @@ module Fluent::Plugin
390
390
  end
391
391
  @bucket.object(s3path).put(put_options)
392
392
 
393
- log.debug "out_s3: completed to write chunk #{dump_unique_id_hex(chunk.unique_id)} with metadata #{chunk.metadata} to s3://#{@s3_bucket}/#{s3path}"
393
+ log.debug { "out_s3: completed to write chunk #{dump_unique_id_hex(chunk.unique_id)} with metadata #{chunk.metadata} to s3://#{@s3_bucket}/#{s3path}" }
394
394
 
395
395
  @values_for_s3_object_chunk.delete(chunk.unique_id)
396
396
 
@@ -19,26 +19,15 @@ module Fluent::Plugin
19
19
  end
20
20
 
21
21
  def extract(io)
22
- path = if io.respond_to?(:path)
23
- io.path
24
- else
25
- temp = Tempfile.new("gzip-temp")
26
- temp.write(io.read)
27
- temp.close
28
- temp.path
29
- end
30
-
31
- stdout, succeeded = Open3.capture2("gzip #{@command_parameter} #{path}")
32
- if succeeded
33
- stdout
34
- else
35
- log.warn "failed to execute gzip command. Fallback to GzipReader. status = #{succeeded}"
36
- begin
37
- io.rewind
38
- Zlib::GzipReader.wrap(io) do |gz|
39
- gz.read
40
- end
41
- end
22
+ begin
23
+ extract_with_command("gzip #{@command_parameter}", io, "gzip-temp")
24
+ rescue Fluent::UnrecoverableError
25
+ raise
26
+ rescue => e
27
+ log.warn "gzip command execution failed: #{e.message}. Fallback to GzipExtractor."
28
+ io.rewind
29
+ extractor = GzipExtractor.new(log: log, decompression_size_limit: @decompression_size_limit)
30
+ extractor.extract(io)
42
31
  end
43
32
  end
44
33
  end
@@ -19,20 +19,12 @@ module Fluent::Plugin
19
19
  end
20
20
 
21
21
  def extract(io)
22
- path = if io.respond_to?(path)
23
- io.path
24
- else
25
- temp = Tempfile.new("xz-temp")
26
- temp.write(io.read)
27
- temp.close
28
- temp.path
29
- end
30
-
31
- stdout, succeeded = Open3.capture2("xz #{@command_parameter} #{path}")
32
- if succeeded
33
- stdout
34
- else
35
- raise "Failed to extract #{path} with xz command."
22
+ begin
23
+ extract_with_command("xz #{@command_parameter}", io, "xz-temp")
24
+ rescue Fluent::UnrecoverableError
25
+ raise
26
+ rescue => e
27
+ raise "Failed to extract with the xz command: #{e.message}"
36
28
  end
37
29
  end
38
30
  end
@@ -19,20 +19,12 @@ module Fluent::Plugin
19
19
  end
20
20
 
21
21
  def extract(io)
22
- path = if io.respond_to?(path)
23
- io.path
24
- else
25
- temp = Tempfile.new("lzop-temp")
26
- temp.write(io.read)
27
- temp.close
28
- temp.path
29
- end
30
-
31
- stdout, succeeded = Open3.capture2("lzop #{@command_parameter} #{path}")
32
- if succeeded
33
- stdout
34
- else
35
- raise "Failed to extract #{path} with lzop command."
22
+ begin
23
+ extract_with_command("lzop #{@command_parameter}", io, "lzop-temp")
24
+ rescue Fluent::UnrecoverableError
25
+ raise
26
+ rescue => e
27
+ raise "Failed to extract with the lzop command: #{e.message}"
36
28
  end
37
29
  end
38
30
  end
data/test/test_in_s3.rb CHANGED
@@ -341,7 +341,7 @@ EOS
341
341
  }
342
342
  ]
343
343
  }
344
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
344
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
345
345
  @sqs_poller.get_messages(anything, anything) do |config, stats|
346
346
  config.before_request.call(stats) if config.before_request
347
347
  stats.request_count += 1
@@ -376,7 +376,7 @@ EOS
376
376
  }
377
377
  ]
378
378
  }
379
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
379
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
380
380
  @sqs_poller.get_messages(anything, anything) do |config, stats|
381
381
  config.before_request.call(stats) if config.before_request
382
382
  stats.request_count += 1
@@ -411,7 +411,7 @@ EOS
411
411
  }
412
412
  ]
413
413
  }
414
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
414
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
415
415
  @sqs_poller.get_messages(anything, anything) do |config, stats|
416
416
  config.before_request.call(stats) if config.before_request
417
417
  stats.request_count += 1
@@ -446,7 +446,7 @@ EOS
446
446
  }
447
447
  ]
448
448
  }
449
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
449
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
450
450
  @sqs_poller.get_messages(anything, anything) do |config, stats|
451
451
  config.before_request.call(stats) if config.before_request
452
452
  stats.request_count += 1
@@ -481,7 +481,7 @@ EOS
481
481
  }
482
482
  ]
483
483
  }
484
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
484
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
485
485
  @sqs_poller.get_messages(anything, anything) do |config, stats|
486
486
  config.before_request.call(stats) if config.before_request
487
487
  stats.request_count += 1
@@ -521,7 +521,7 @@ EOS
521
521
  }
522
522
  ]
523
523
  }
524
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
524
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
525
525
  @sqs_poller.get_messages(anything, anything) do |config, stats|
526
526
  config.before_request.call(stats) if config.before_request
527
527
  stats.request_count += 1
@@ -569,7 +569,7 @@ EOS
569
569
  }
570
570
  ]
571
571
  }
572
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
572
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
573
573
  @sqs_poller.get_messages(anything, anything) do |config, stats|
574
574
  config.before_request.call(stats) if config.before_request
575
575
  stats.request_count += 1
@@ -620,7 +620,7 @@ EOS
620
620
  }
621
621
  ]
622
622
  }
623
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
623
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
624
624
  @sqs_poller.get_messages(anything, anything) do |config, stats|
625
625
  config.before_request.call(stats) if config.before_request
626
626
  stats.request_count += 1
@@ -640,6 +640,91 @@ EOS
640
640
  assert_equal(expected_records, events.map {|_tag, _time, record| record })
641
641
  end
642
642
 
643
+ data(
644
+ "limit_gzip" => { type: "gzip", input: "StringIO", limit: 10, expected_error: true },
645
+ "limit_text" => { type: "text", input: "StringIO", limit: 10, expected_error: true },
646
+ "limit_gzip_command1" => { type: "gzip_command", input: "StringIO", limit: 10, expected_error: true },
647
+ "limit_gzip_command2" => { type: "gzip_command", input: "Tempfile", limit: 10, expected_error: true },
648
+ "normal_gzip_command" => { type: "gzip_command", input: "Tempfile", limit: 100, expected_error: false },
649
+ )
650
+ def test_decompression_size_limit(data)
651
+ store_type = data[:type]
652
+ input_type = data[:input]
653
+ limit = data[:limit]
654
+ setup_mocks
655
+
656
+ config = <<~CONF
657
+ #{CONFIG}
658
+ check_apikey_on_start false
659
+ store_as #{store_type}
660
+ format none
661
+ decompression_size_limit #{limit}
662
+ CONF
663
+
664
+ d = create_driver(config)
665
+
666
+ s3_object = stub(Object.new)
667
+ s3_response = stub(Object.new)
668
+ s3_response.body {
669
+ content = "#{'a'*10}\n#{'b'*10}\n"
670
+
671
+ # Switching between Tempfile and StringIO to cover both branches of the
672
+ # `io.respond_to?(:path)` condition in `extract_with_command`.
673
+ # This ensures that:
674
+ # 1. The StringIO route correctly uses TextExtractor to create a protected temporary file.
675
+ # 2. The Tempfile route correctly limits the output size during Open3.popen3 execution.
676
+ io = (input_type == "Tempfile") ? Tempfile.new : StringIO.new
677
+
678
+ case store_type
679
+ when "gzip", "gzip_command"
680
+ io.binmode
681
+ Zlib::GzipWriter.wrap(io) { |gz|
682
+ gz.write content
683
+ gz.finish
684
+ }
685
+ when "text"
686
+ io.write content
687
+ end
688
+
689
+ io.rewind
690
+ io
691
+ }
692
+ s3_object.get { s3_response }
693
+ @s3_bucket.object(anything).at_least(1) { s3_object }
694
+
695
+ body = {
696
+ "Records" => [
697
+ {
698
+ "s3" => {
699
+ "object" => {
700
+ "key" => "test_key"
701
+ }
702
+ }
703
+ }
704
+ ]
705
+ }
706
+ message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
707
+ @sqs_poller.get_messages(anything, anything) do |config, stats|
708
+ config.before_request.call(stats) if config.before_request
709
+ stats.request_count += 1
710
+ if stats.request_count >= 1
711
+ d.instance.instance_variable_set(:@running, false)
712
+ end
713
+ [message]
714
+ end
715
+ d.run
716
+
717
+ if data[:expected_error]
718
+ # Verify the protection mechanism: ensure SizeLimitError is logged.
719
+ assert_true d.logs.any? { |l| l.include?("Extracted data exceeds limit of #{limit} bytes") }
720
+ else
721
+ # Verify the normal execution path: ensure data is correctly extracted via Open3.popen3.
722
+ expected_records = [{ "message" => "#{'a'*10}\n" }, { "message" => "#{'b'*10}\n" }]
723
+ assert_equal(expected_records, d.events.map {|_tag, _time, record| record })
724
+ assert_false d.logs.any? { |l| l.include?("error_class") }
725
+ end
726
+ end
727
+
643
728
  def test_regexp_matching
644
729
  setup_mocks
645
730
  d = create_driver(CONFIG + "\ncheck_apikey_on_start false\nstore_as text\nformat none\nmatch_regexp .*_key?")
@@ -661,7 +746,7 @@ EOS
661
746
  }
662
747
  ]
663
748
  }
664
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
749
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
665
750
  @sqs_poller.get_messages(anything, anything) do |config, stats|
666
751
  config.before_request.call(stats) if config.before_request
667
752
  stats.request_count += 1
@@ -690,7 +775,7 @@ EOS
690
775
  }
691
776
  ]
692
777
  }
693
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
778
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
694
779
  @sqs_poller.get_messages(anything, anything) do |config, stats|
695
780
  config.before_request.call(stats) if config.before_request
696
781
  stats.request_count += 1
@@ -735,7 +820,7 @@ EOS
735
820
  }
736
821
  }
737
822
 
738
- message = Struct::StubMessage.new(1, 1, Yajl.dump(body))
823
+ message = Struct::StubMessage.new(1, 1, JSON.generate(body))
739
824
  @sqs_poller.get_messages(anything, anything) do |config, stats|
740
825
  config.before_request.call(stats) if config.before_request
741
826
  stats.request_count += 1
@@ -749,4 +834,281 @@ EOS
749
834
  assert_equal({ "message" => "aaa" }, events.first[2])
750
835
  end
751
836
 
837
+ POLL_CONFIG = CONFIG + "\ncheck_apikey_on_start false\nstore_as text\nformat none\n"
838
+
839
+ EVENT_BRIDGE_POLL_CONFIG =
840
+ CONFIG.sub("<sqs>", "<sqs>\n event_bridge_mode true") +
841
+ "\ncheck_apikey_on_start false\nstore_as text\nformat none\n"
842
+
843
+ def notification(key)
844
+ JSON.generate({ "Records" => [{ "s3" => { "object" => { "key" => key } } }] })
845
+ end
846
+
847
+ def gzipped(text)
848
+ buffer = StringIO.new(String.new, "wb")
849
+ Zlib::GzipWriter.wrap(buffer) { |gz| gz.write(text) }
850
+ buffer.string
851
+ end
852
+
853
+ def stub_s3_object(payload: "aaa", get_error: nil)
854
+ s3_response = stub(Object.new)
855
+ s3_response.body { StringIO.new(payload) }
856
+ s3_object = stub(Object.new)
857
+ s3_object.get { raise get_error if get_error; s3_response }
858
+ @s3_bucket.object(anything).at_least(0) do |key|
859
+ raise ArgumentError, ":key must not be blank" if key.to_s.empty?
860
+ raise ArgumentError, "invalid byte sequence in UTF-8" unless key.to_s.valid_encoding?
861
+ s3_object
862
+ end
863
+ end
864
+
865
+ def poll_once(d, message)
866
+ deleted = []
867
+ @sqs_poller.delete_messages(anything) { |messages| deleted.concat(messages) }
868
+ @sqs_poller.get_messages(anything, anything) do |config, stats|
869
+ config.before_request.call(stats) if config.before_request
870
+ stats.request_count += 1
871
+ if stats.request_count >= 1
872
+ d.instance.instance_variable_set(:@running, false)
873
+ end
874
+ [message]
875
+ end
876
+ deleted
877
+ end
878
+
879
+ def warn_logs(d)
880
+ d.logs.select { |log| log.include?("[warn]") }
881
+ end
882
+
883
+ data(
884
+ "body is not JSON" => "hello",
885
+ "body is empty" => "",
886
+ "body is not a JSON object" => "[]",
887
+ "body is null" => "null",
888
+ "Records is empty" => '{"Records":[]}',
889
+ "record without s3" => '{"Records":[{}]}',
890
+ "record without object" => '{"Records":[{"s3":{}}]}',
891
+ "record without key" => '{"Records":[{"s3":{"object":{}}}]}',
892
+ "Records is a string" => '{"Records":"test_key"}',
893
+ "Records is an object" => '{"Records":{"s3":{}}}',
894
+ "key is not a string" => '{"Records":[{"s3":{"object":{"key":1}}}]}',
895
+ "key is empty" => '{"Records":[{"s3":{"object":{"key":""}}}]}',
896
+ "key holds invalid utf-8" => %Q({"Records":[{"s3":{"object":{"key":"a\xFF\xFEb"}}}]}),
897
+ "key decodes to invalid utf-8" => '{"Records":[{"s3":{"object":{"key":"%FF"}}}]}',
898
+ )
899
+ def test_unrecoverable_message_is_deleted(body)
900
+ setup_mocks
901
+ stub_s3_object
902
+ d = create_driver(POLL_CONFIG)
903
+
904
+ message = Struct::StubMessage.new(1, 1, body)
905
+ deleted = poll_once(d, message)
906
+
907
+ assert_nothing_raised { d.run {} }
908
+ assert_equal([message], deleted)
909
+ assert_true(warn_logs(d).any? { |log| log.include?("Skipping SQS message") })
910
+ end
911
+
912
+ def test_unrecoverable_message_in_event_bridge_mode_is_deleted
913
+ setup_mocks
914
+ stub_s3_object
915
+ d = create_driver(EVENT_BRIDGE_POLL_CONFIG)
916
+
917
+ message = Struct::StubMessage.new(1, 1, '{"detail":{}}')
918
+ deleted = poll_once(d, message)
919
+
920
+ assert_nothing_raised { d.run {} }
921
+ assert_equal([message], deleted)
922
+ assert_true(warn_logs(d).any? { |log| log.include?("Skipping SQS message") })
923
+ end
924
+
925
+ data(
926
+ "no Records property" => "{}",
927
+ "sns envelope" => '{"Type":"Notification","Message":"{}"}',
928
+ "s3 test event" => '{"Service":"Amazon S3","Event":"s3:TestEvent"}',
929
+ )
930
+ def test_message_that_is_not_an_s3_notification_is_reported(body)
931
+ setup_mocks
932
+ stub_s3_object
933
+ d = create_driver(POLL_CONFIG)
934
+ mock(d.instance).process(anything).never
935
+
936
+ message = Struct::StubMessage.new(1, 1, body)
937
+ deleted = poll_once(d, message)
938
+ d.run {}
939
+
940
+ assert_equal([message], deleted)
941
+ assert_true(warn_logs(d).any? { |log| log.include?("not an S3 notification") })
942
+ end
943
+
944
+ data(
945
+ "missing object" => "NoSuchKey",
946
+ "missing bucket" => "NoSuchBucket",
947
+ "access denied" => "AccessDenied",
948
+ "throttled" => "SlowDown",
949
+ )
950
+ def test_service_error_keeps_the_message_for_redelivery(error_name)
951
+ setup_mocks
952
+ stub_s3_object(get_error: Aws::S3::Errors.const_get(error_name).new(nil, error_name))
953
+ d = create_driver(POLL_CONFIG)
954
+
955
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
956
+ deleted = poll_once(d, message)
957
+ d.run {}
958
+
959
+ assert_equal([], deleted)
960
+ assert_true(warn_logs(d).any? { |log| log.include?("Failed to handle SQS message") })
961
+ end
962
+
963
+ def test_networking_error_keeps_the_message_for_redelivery
964
+ setup_mocks
965
+ stub_s3_object(get_error: Seahorse::Client::NetworkingError.new(Errno::ECONNRESET.new))
966
+ d = create_driver(POLL_CONFIG)
967
+
968
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
969
+ deleted = poll_once(d, message)
970
+ d.run {}
971
+
972
+ assert_equal([], deleted)
973
+ end
974
+
975
+ data(
976
+ "gzip" => "gzip",
977
+ "gzip_command" => "gzip_command",
978
+ "lzo" => "lzo",
979
+ "lzma2" => "lzma2",
980
+ )
981
+ def test_corrupt_archive_is_deleted(store_type)
982
+ setup_mocks
983
+ stub_s3_object(payload: "this is not an archive")
984
+ begin
985
+ d = create_driver(POLL_CONFIG.sub("store_as text", "store_as #{store_type}"))
986
+ rescue Fluent::ConfigError => e
987
+ pend(e.message)
988
+ end
989
+
990
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
991
+ deleted = poll_once(d, message)
992
+ d.run {}
993
+
994
+ assert_equal([message], deleted)
995
+ assert_true(warn_logs(d).any? { |log| log.include?("Skipping SQS message") })
996
+ end
997
+
998
+ def test_object_over_the_decompression_limit_is_deleted
999
+ setup_mocks
1000
+ stub_s3_object(payload: gzipped("a" * 100))
1001
+ d = create_driver(POLL_CONFIG.sub("store_as text", "store_as gzip") + "\ndecompression_size_limit 10\n")
1002
+
1003
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
1004
+ deleted = poll_once(d, message)
1005
+ d.run {}
1006
+
1007
+ assert_equal([message], deleted)
1008
+ assert_true(d.logs.any? { |log| log.include?("Extracted data exceeds limit") })
1009
+ end
1010
+
1011
+ def test_unrecoverable_message_is_not_redelivered
1012
+ setup_mocks
1013
+ stub_s3_object
1014
+ d = create_driver(POLL_CONFIG)
1015
+
1016
+ deleted = []
1017
+ attempts = 0
1018
+ @sqs_poller.delete_messages(anything) { |messages| deleted.concat(messages) }
1019
+ message = Struct::StubMessage.new("mid-1", "rh-1", '{"Records":[]}')
1020
+ @sqs_poller.get_messages(anything, anything) do |config, stats|
1021
+ config.before_request.call(stats) if config.before_request
1022
+ stats.request_count += 1
1023
+ if stats.request_count >= 5
1024
+ d.instance.instance_variable_set(:@running, false)
1025
+ end
1026
+ next [] unless deleted.empty?
1027
+ attempts += 1
1028
+ [message]
1029
+ end
1030
+ d.run {}
1031
+
1032
+ assert_equal([message], deleted)
1033
+ assert_equal(1, attempts)
1034
+ end
1035
+
1036
+ def test_a_bug_in_the_plugin_keeps_the_message_for_redelivery
1037
+ setup_mocks
1038
+ stub_s3_object
1039
+ d = create_driver(POLL_CONFIG)
1040
+ stub(d.instance).is_valid_queue(anything) { raise NoMethodError, "undefined method 'foo' for nil" }
1041
+
1042
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
1043
+ deleted = poll_once(d, message)
1044
+ d.run {}
1045
+
1046
+ assert_equal([], deleted)
1047
+ assert_true(warn_logs(d).any? { |log| log.include?("Failed to handle SQS message") })
1048
+ end
1049
+
1050
+ def test_unparsable_object_is_deleted
1051
+ setup_mocks
1052
+ stub_s3_object(payload: "\xC1")
1053
+ d = create_driver(POLL_CONFIG.sub("format none", "<parse>\n @type msgpack\n</parse>"))
1054
+
1055
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
1056
+ deleted = poll_once(d, message)
1057
+ d.run {}
1058
+
1059
+ assert_equal([message], deleted)
1060
+ assert_true(warn_logs(d).any? { |log| log.include?("Skipping SQS message") })
1061
+ end
1062
+
1063
+ def test_deleting_warn_names_the_object_key
1064
+ setup_mocks
1065
+ stub_s3_object(payload: "this is not an archive")
1066
+ d = create_driver(POLL_CONFIG.sub("store_as text", "store_as gzip"))
1067
+
1068
+ message = Struct::StubMessage.new(1, 1, notification("path/to/broken.gz"))
1069
+ deleted = poll_once(d, message)
1070
+ d.run {}
1071
+
1072
+ assert_equal([message], deleted)
1073
+ assert_true(warn_logs(d).any? { |log| log.include?(%Q(key="path/to/broken.gz")) })
1074
+ assert_true(warn_logs(d).any? { |log| log.include?("in_s3.rb:") })
1075
+ end
1076
+
1077
+ def test_deleting_warn_truncates_a_long_object_key
1078
+ setup_mocks
1079
+ stub_s3_object(payload: "this is not an archive")
1080
+ d = create_driver(POLL_CONFIG.sub("store_as text", "store_as gzip"))
1081
+
1082
+ message = Struct::StubMessage.new(1, 1, notification("\u3042" * 400))
1083
+ deleted = poll_once(d, message)
1084
+ d.run {}
1085
+
1086
+ assert_equal([message], deleted)
1087
+ line = warn_logs(d).find { |log| log.include?("Skipping SQS message") }
1088
+ assert_operator(line[/key="(.*)"/, 1].length, :<=, Fluent::Plugin::S3Input::LOG_MAX_BYTES)
1089
+ end
1090
+
1091
+ def test_a_truncated_object_key_is_still_valid_utf8
1092
+ d = create_driver(POLL_CONFIG)
1093
+ logged = d.instance.__send__(:loggable, "\u3042" * 400)
1094
+
1095
+ assert_true(logged.valid_encoding?)
1096
+ assert_operator(logged.bytesize, :<=, Fluent::Plugin::S3Input::LOG_MAX_BYTES)
1097
+ assert_nothing_raised { JSON.generate([logged]) }
1098
+ end
1099
+
1100
+ def test_valid_message_is_processed_and_deleted
1101
+ setup_mocks
1102
+ stub_s3_object
1103
+ d = create_driver(POLL_CONFIG)
1104
+
1105
+ message = Struct::StubMessage.new(1, 1, notification("test_key"))
1106
+ deleted = poll_once(d, message)
1107
+ d.run(expect_emits: 1)
1108
+
1109
+ assert_equal([message], deleted)
1110
+ assert_equal({ "message" => "aaa" }, d.events.first[2])
1111
+ assert_equal([], warn_logs(d))
1112
+ end
1113
+
752
1114
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: fluent-plugin-s3
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.8.4
4
+ version: 1.8.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - Sadayuki Furuhashi
@@ -214,7 +214,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
214
214
  - !ruby/object:Gem::Version
215
215
  version: '0'
216
216
  requirements: []
217
- rubygems_version: 4.0.6
217
+ rubygems_version: 4.0.18
218
218
  specification_version: 4
219
219
  summary: Amazon S3 output plugin for Fluentd event collector
220
220
  test_files: