logstash-output-kusto 2.1.6-java → 2.2.0-java

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.
@@ -2,6 +2,7 @@
2
2
  require 'logstash/outputs/kusto'
3
3
  require 'logstash/codecs/plain'
4
4
  require 'logstash/event'
5
+ require 'tmpdir'
5
6
 
6
7
  describe LogStash::Outputs::Kusto do
7
8
 
@@ -19,6 +20,17 @@ describe LogStash::Outputs::Kusto do
19
20
  } }
20
21
 
21
22
  describe '#register' do
23
+ it 'defaults ingestion mode to queued' do
24
+ kusto = described_class.new(options)
25
+
26
+ expect(kusto.ingestion_mode).to eq('queued')
27
+ end
28
+
29
+ it 'requires the explicit streaming mode name' do
30
+ expect do
31
+ described_class.new(options.merge('ingestion_mode' => 'managed_streaming'))
32
+ end.to raise_error(LogStash::ConfigurationError)
33
+ end
22
34
 
23
35
  it 'doesnt allow the path to start with a dynamic string' do
24
36
  kusto = described_class.new(options.merge( {'path' => '/%{name}'} ))
@@ -51,6 +63,385 @@ describe LogStash::Outputs::Kusto do
51
63
  kusto.close
52
64
  end
53
65
 
66
+ it 'requires path for queued ingestion' do
67
+ queued_options = options.reject { |key, _| key == 'path' }
68
+ kusto = described_class.new(queued_options)
69
+
70
+ expect { kusto.register }.to raise_error(LogStash::ConfigurationError, /path/)
71
+ end
72
+
73
+ it 'does not require path for streaming ingestion' do
74
+ Dir.mktmpdir('kusto-streaming') do |directory|
75
+ streaming_options = options
76
+ .reject { |key, _| key == 'path' }
77
+ .merge(
78
+ 'ingestion_mode' => 'streaming',
79
+ 'streaming_temp_directory' => directory
80
+ )
81
+ ingestor = instance_double(LogStash::Outputs::Kusto::Ingestor, stop: nil)
82
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
83
+ kusto = described_class.new(streaming_options)
84
+
85
+ expect { kusto.register }.not_to raise_error
86
+ kusto.close
87
+ end
88
+ end
89
+
90
+ it 'uses a private destination spool below path.data by default' do
91
+ Dir.mktmpdir('kusto-path-data') do |path_data|
92
+ allow(LogStash::SETTINGS).to receive(:get).and_call_original
93
+ allow(LogStash::SETTINGS).to receive(:get).with('path.data').and_return(path_data)
94
+ ingestor = instance_double(LogStash::Outputs::Kusto::Ingestor, stop: nil)
95
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
96
+ kusto = described_class.new(
97
+ options.reject { |key, _| key == 'path' }.merge(
98
+ 'ingestion_mode' => 'streaming'
99
+ )
100
+ )
101
+
102
+ kusto.register
103
+
104
+ spool = kusto.instance_variable_get(:@streaming_temp_directory)
105
+ expect(spool).to start_with(File.realpath(path_data))
106
+ expect(File.stat(spool).mode & 0o777).to eq(0o700)
107
+ kusto.close
108
+ end
109
+ end
110
+
111
+ it 'rejects unsafe streaming file and directory modes' do
112
+ {
113
+ 'dir_mode' => 0o777,
114
+ 'file_mode' => 0o666
115
+ }.each do |setting, mode|
116
+ Dir.mktmpdir('kusto-unsafe-mode') do |directory|
117
+ kusto = described_class.new(options.merge(
118
+ 'ingestion_mode' => 'streaming',
119
+ 'streaming_temp_directory' => directory,
120
+ setting => mode
121
+ ))
122
+
123
+ expect { kusto.register }.to raise_error(LogStash::ConfigurationError, /must not allow/)
124
+ end
125
+ end
126
+ end
127
+
128
+ it 'rejects a symlink as the streaming spool directory' do
129
+ skip 'Windows symlink creation requires elevated privileges' if Gem.win_platform?
130
+
131
+ Dir.mktmpdir('kusto-streaming-symlink') do |directory|
132
+ target = File.join(directory, 'target')
133
+ link = File.join(directory, 'link')
134
+ Dir.mkdir(target)
135
+ File.symlink(target, link)
136
+ kusto = described_class.new(options.merge(
137
+ 'ingestion_mode' => 'streaming',
138
+ 'streaming_temp_directory' => link
139
+ ))
140
+
141
+ expect { kusto.register }.to raise_error(LogStash::ConfigurationError, /must not be a symlink/)
142
+ end
143
+ end
144
+
145
+ it 'rejects a spool below a non-sticky world-writable parent' do
146
+ skip 'POSIX permission validation is not available on Windows' if Gem.win_platform?
147
+
148
+ Dir.mktmpdir('kusto-streaming-parent') do |directory|
149
+ unsafe_parent = File.join(directory, 'unsafe')
150
+ Dir.mkdir(unsafe_parent, 0o777)
151
+ File.chmod(0o777, unsafe_parent)
152
+ spool = File.join(unsafe_parent, 'spool')
153
+ kusto = described_class.new(options.merge(
154
+ 'ingestion_mode' => 'streaming',
155
+ 'streaming_temp_directory' => spool
156
+ ))
157
+
158
+ expect { kusto.register }.to raise_error(
159
+ LogStash::ConfigurationError,
160
+ /writable by untrusted users/
161
+ )
162
+ end
163
+ end
164
+
165
+ it 'skips unsupported directory fsync on Windows' do
166
+ output = described_class.allocate
167
+ allow(Gem).to receive(:win_platform?).and_return(true)
168
+ expect(File).not_to receive(:open)
169
+
170
+ expect { output.send(:fsync_directory, 'C:/spool') }.not_to raise_error
171
+ end
172
+
173
+ it 'recovers streaming spool files after restart' do
174
+ Dir.mktmpdir('kusto-streaming-recovery') do |directory|
175
+ batch_directory = File.join(directory, 'batch-existing.ready')
176
+ FileUtils.mkdir_p(batch_directory)
177
+ recovered_file = File.join(
178
+ batch_directory,
179
+ 'stream-000000-2a11f3ee-9dd7-42ae-99bc-89046a8b8d65.json'
180
+ )
181
+ File.write(recovered_file, "{\"id\":1}\n")
182
+ ingestor = instance_double(
183
+ LogStash::Outputs::Kusto::Ingestor,
184
+ upload_async: nil,
185
+ stop: nil
186
+ )
187
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
188
+ kusto = described_class.new(options.merge(
189
+ 'ingestion_mode' => 'streaming',
190
+ 'streaming_temp_directory' => directory
191
+ ))
192
+
193
+ expect(ingestor).to receive(:upload_async).with(File.realpath(recovered_file), true)
194
+ kusto.register
195
+ kusto.close
196
+ end
197
+ end
198
+
199
+ it 'discards incomplete streaming batches during recovery' do
200
+ Dir.mktmpdir('kusto-streaming-recovery') do |directory|
201
+ incomplete_directory = File.join(directory, '.batch-incomplete.tmp')
202
+ FileUtils.mkdir_p(incomplete_directory)
203
+ File.write(File.join(incomplete_directory, 'stream-000000.json'), "{\"id\":1}\n")
204
+ ingestor = instance_double(
205
+ LogStash::Outputs::Kusto::Ingestor,
206
+ upload_async: nil,
207
+ stop: nil
208
+ )
209
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
210
+ kusto = described_class.new(options.merge(
211
+ 'ingestion_mode' => 'streaming',
212
+ 'streaming_temp_directory' => directory
213
+ ))
214
+
215
+ kusto.register
216
+
217
+ expect(ingestor).not_to have_received(:upload_async)
218
+ expect(File.exist?(incomplete_directory)).to be(false)
219
+ kusto.close
220
+ end
221
+ end
222
+
223
+ it 'prevents multiple outputs from sharing one streaming spool' do
224
+ Dir.mktmpdir('kusto-streaming-lock') do |directory|
225
+ ingestor = instance_double(LogStash::Outputs::Kusto::Ingestor, stop: nil)
226
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
227
+ first = described_class.new(options.merge(
228
+ 'ingestion_mode' => 'streaming',
229
+ 'streaming_temp_directory' => directory
230
+ ))
231
+ second = described_class.new(options.merge(
232
+ 'ingestion_mode' => 'streaming',
233
+ 'streaming_temp_directory' => directory
234
+ ))
235
+
236
+ first.register
237
+ expect { second.register }.to raise_error(LogStash::ConfigurationError, /already in use/)
238
+ first.close
239
+ end
240
+ end
241
+
242
+ it 'rejects unsafe files during streaming recovery' do
243
+ skip 'Windows symlink creation requires elevated privileges' if Gem.win_platform?
244
+
245
+ Dir.mktmpdir('kusto-streaming-recovery') do |directory|
246
+ batch_directory = File.join(directory, 'batch-existing.ready')
247
+ FileUtils.mkdir_p(batch_directory)
248
+ outside_file = File.join(directory, 'outside.json')
249
+ File.write(outside_file, "{\"id\":\"forged\"}\n")
250
+ recovered_file = File.join(
251
+ batch_directory,
252
+ 'stream-000000-2a11f3ee-9dd7-42ae-99bc-89046a8b8d65.json'
253
+ )
254
+ File.symlink(outside_file, recovered_file)
255
+ ingestor = instance_double(LogStash::Outputs::Kusto::Ingestor, stop: nil)
256
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
257
+ kusto = described_class.new(options.merge(
258
+ 'ingestion_mode' => 'streaming',
259
+ 'streaming_temp_directory' => directory
260
+ ))
261
+
262
+ expect { kusto.register }.to raise_error(LogStash::ConfigurationError, /unsafe spool file/)
263
+ end
264
+ end
265
+
266
+ it 'rejects a non-positive streaming request size' do
267
+ kusto = described_class.new(options.merge(
268
+ 'ingestion_mode' => 'streaming',
269
+ 'streaming_max_request_bytes' => 0
270
+ ))
271
+
272
+ expect { kusto.register }.to raise_error(LogStash::ConfigurationError, /streaming_max_request_bytes/)
273
+ end
274
+
275
+ {
276
+ 'streaming_max_retry_attempts' => -1,
277
+ 'streaming_retry_backoff_seconds' => 0,
278
+ 'streaming_concurrent_requests' => 0
279
+ }.each do |setting, value|
280
+ it "rejects invalid #{setting}" do
281
+ kusto = described_class.new(options.merge(
282
+ 'ingestion_mode' => 'streaming',
283
+ setting => value
284
+ ))
285
+
286
+ expect { kusto.register }.to raise_error(LogStash::ConfigurationError, /#{setting}/)
287
+ end
288
+ end
289
+
290
+ end
291
+
292
+ describe '#multi_receive_encoded with streaming' do
293
+ let(:streaming_temp_directory) { Dir.mktmpdir('kusto-streaming-spec') }
294
+ let(:streaming_options) do
295
+ options.reject { |key, _| key == 'path' }.merge(
296
+ 'ingestion_mode' => 'streaming',
297
+ 'streaming_max_request_bytes' => 10,
298
+ 'streaming_temp_directory' => streaming_temp_directory,
299
+ 'delete_temp_files' => false
300
+ )
301
+ end
302
+ let(:ingestor) do
303
+ instance_double(LogStash::Outputs::Kusto::Ingestor, upload_async: nil, stop: nil)
304
+ end
305
+ let(:kusto) { described_class.new(streaming_options) }
306
+
307
+ before do
308
+ @uploads = []
309
+ @uploads_mutex = Mutex.new
310
+ allow(ingestor).to receive(:upload_async) do |path, delete_on_success|
311
+ @uploads_mutex.synchronize do
312
+ @uploads << {
313
+ path: path,
314
+ payload: File.binread(path),
315
+ delete_on_success: delete_on_success
316
+ }
317
+ end
318
+ end
319
+ allow(LogStash::Outputs::Kusto::Ingestor).to receive(:new).and_return(ingestor)
320
+ kusto.register
321
+ end
322
+
323
+ after do
324
+ kusto.close
325
+ FileUtils.rm_rf(streaming_temp_directory)
326
+ end
327
+
328
+ it 'writes byte-bounded spool files and enqueues them in order' do
329
+ events_and_encoded = [
330
+ [LogStash::Event.new('id' => 1), "123456\n"],
331
+ [LogStash::Event.new('id' => 2), "789\n"],
332
+ [LogStash::Event.new('id' => 3), "abcde\n"]
333
+ ]
334
+
335
+ kusto.multi_receive_encoded(events_and_encoded)
336
+
337
+ expect(@uploads.map { |upload| upload[:payload] })
338
+ .to eq(["123456\n", "789\nabcde\n"])
339
+ expect(@uploads).to all(include(delete_on_success: false))
340
+ expect(@uploads.map { |upload| File.basename(File.dirname(upload[:path])) }.uniq.length).to eq(1)
341
+ expect(File.basename(File.dirname(@uploads.first[:path]))).to match(/\Abatch-.*\.ready\z/)
342
+ expect(Dir.glob(File.join(streaming_temp_directory, '.batch-*.tmp'))).to be_empty
343
+ expect(File.stat(File.dirname(@uploads.first[:path])).mode & 0o777).to eq(0o700)
344
+ expect(File.stat(@uploads.first[:path]).mode & 0o777).to eq(0o600)
345
+ end
346
+
347
+ it 'writes a single event larger than the threshold intact' do
348
+ oversized = "#{'x' * 20}\n"
349
+
350
+ kusto.multi_receive_encoded([[LogStash::Event.new('id' => 1), oversized]])
351
+
352
+ expect(@uploads.map { |upload| upload[:payload] }).to eq([oversized])
353
+ end
354
+
355
+ it 'does not call Kusto for an empty Logstash batch' do
356
+ kusto.multi_receive_encoded([])
357
+
358
+ expect(@uploads).to be_empty
359
+ end
360
+
361
+ it 'uses encoded byte size when creating requests' do
362
+ multibyte = "\u20ac\u20ac\n"
363
+
364
+ kusto.multi_receive_encoded([
365
+ [LogStash::Event.new('id' => 1), multibyte],
366
+ [LogStash::Event.new('id' => 2), "1234\n"]
367
+ ])
368
+
369
+ expect(@uploads.map { |upload| upload[:payload].bytes })
370
+ .to eq([multibyte.bytes, "1234\n".bytes])
371
+ end
372
+
373
+ it 'creates every spool file before starting ingestion' do
374
+ events_and_encoded = [
375
+ [LogStash::Event.new('id' => 1), "123456\n"],
376
+ [LogStash::Event.new('id' => 2), "789\n"],
377
+ [LogStash::Event.new('id' => 3), "abcde\n"]
378
+ ]
379
+ observed_file_count = nil
380
+ allow(ingestor).to receive(:upload_async) do |path, _delete_on_success|
381
+ observed_file_count ||= Dir.glob(
382
+ File.join(streaming_temp_directory, 'batch-*.ready', '*.json')
383
+ ).length
384
+ @uploads << { path: path, payload: File.binread(path) }
385
+ end
386
+
387
+ kusto.multi_receive_encoded(events_and_encoded)
388
+
389
+ expect(observed_file_count).to eq(2)
390
+ end
391
+
392
+ it 'cleans the entire uncommitted batch when a spool write fails' do
393
+ allow(kusto).to receive(:write_streaming_file).and_call_original
394
+ allow(kusto).to receive(:write_streaming_file).with(anything, ["abcde\n"])
395
+ .and_raise(Errno::ENOSPC)
396
+
397
+ expect do
398
+ kusto.multi_receive_encoded([
399
+ [LogStash::Event.new('id' => 1), "123456\n"],
400
+ [LogStash::Event.new('id' => 2), "abcde\n"]
401
+ ])
402
+ end.to raise_error(Errno::ENOSPC)
403
+
404
+ expect(@uploads).to be_empty
405
+ expect(Dir.glob(File.join(streaming_temp_directory, '.batch-*.tmp'))).to be_empty
406
+ expect(Dir.glob(File.join(streaming_temp_directory, 'batch-*.ready'))).to be_empty
407
+ end
408
+
409
+ it 'preserves a committed batch when a post-rename operation fails' do
410
+ spool_directory = kusto.instance_variable_get(:@streaming_temp_directory)
411
+ allow(kusto).to receive(:fsync_directory).and_call_original
412
+ allow(kusto).to receive(:fsync_directory).with(spool_directory)
413
+ .and_raise(Errno::EIO)
414
+
415
+ expect do
416
+ kusto.multi_receive_encoded([
417
+ [LogStash::Event.new('id' => 1), "durable\n"]
418
+ ])
419
+ end.to raise_error(Errno::EIO)
420
+
421
+ expect(@uploads).to be_empty
422
+ expect(Dir.glob(File.join(streaming_temp_directory, '.batch-*.tmp'))).to be_empty
423
+ committed_files = Dir.glob(
424
+ File.join(streaming_temp_directory, 'batch-*.ready', 'stream-*.json')
425
+ )
426
+ expect(committed_files.length).to eq(1)
427
+ expect(File.binread(committed_files.first)).to eq("durable\n")
428
+ end
429
+
430
+ it 'creates collision-free atomic batches from concurrent Logstash workers' do
431
+ threads = 8.times.map do |index|
432
+ Thread.new do
433
+ kusto.multi_receive_encoded([
434
+ [LogStash::Event.new('id' => index), "#{index}-payload\n"]
435
+ ])
436
+ end
437
+ end
438
+ threads.each(&:join)
439
+
440
+ paths = @uploads.map { |upload| upload[:path] }
441
+ expect(paths.uniq.length).to eq(8)
442
+ expect(paths.map { |path| File.dirname(path) }.uniq.length).to eq(8)
443
+ expect(Dir.glob(File.join(streaming_temp_directory, '.batch-*.tmp'))).to be_empty
444
+ end
54
445
  end
55
446
 
56
447
  end
data/version CHANGED
@@ -1 +1 @@
1
- 2.1.6
1
+ 2.2.0
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: logstash-output-kusto
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.1.6
4
+ version: 2.2.0
5
5
  platform: java
6
6
  authors:
7
7
  - Tamir Kamara
@@ -145,8 +145,10 @@ files:
145
145
  - lib/logstash/outputs/kusto.rb
146
146
  - lib/logstash/outputs/kusto/ingestor.rb
147
147
  - lib/logstash/outputs/kusto/interval.rb
148
+ - lib/logstash/outputs/kusto/streaming_chunker.rb
148
149
  - logstash-output-kusto.gemspec
149
150
  - spec/outputs/kusto/ingestor_spec.rb
151
+ - spec/outputs/kusto/streaming_chunker_spec.rb
150
152
  - spec/outputs/kusto_spec.rb
151
153
  - spec/spec_helpers.rb
152
154
  - vendor/jar-dependencies/com/azure/azure-core-http-netty/1.16.3/azure-core-http-netty-1.16.3.jar
@@ -215,7 +217,7 @@ files:
215
217
  - vendor/jar-dependencies/org/codehaus/woodstox/stax2-api/4.2.2/stax2-api-4.2.2.jar
216
218
  - vendor/jar-dependencies/org/jetbrains/annotations/24.1.0/annotations-24.1.0.jar
217
219
  - vendor/jar-dependencies/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar
218
- - vendor/jar-dependencies/org/logstash/outputs/logstash-output-kusto/2.1.6/logstash-output-kusto-2.1.6.jar
220
+ - vendor/jar-dependencies/org/logstash/outputs/logstash-output-kusto/2.2.0/logstash-output-kusto-2.2.0.jar
219
221
  - vendor/jar-dependencies/org/ow2/asm/asm/9.9.1/asm-9.9.1.jar
220
222
  - vendor/jar-dependencies/org/reactivestreams/reactive-streams/1.0.4/reactive-streams-1.0.4.jar
221
223
  - vendor/jar-dependencies/org/slf4j/slf4j-api/2.0.17/slf4j-api-2.0.17.jar
@@ -242,10 +244,11 @@ required_rubygems_version: !ruby/object:Gem::Requirement
242
244
  - !ruby/object:Gem::Version
243
245
  version: '0'
244
246
  requirements: []
245
- rubygems_version: 3.7.2
247
+ rubygems_version: 4.0.3
246
248
  specification_version: 4
247
249
  summary: Writes events to Azure Data Explorer (Kusto)
248
250
  test_files:
249
251
  - spec/outputs/kusto/ingestor_spec.rb
252
+ - spec/outputs/kusto/streaming_chunker_spec.rb
250
253
  - spec/outputs/kusto_spec.rb
251
254
  - spec/spec_helpers.rb