fluent-plugin-kusto 1.0.0 → 1.1.1.beta
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/README.md +63 -5
- data/lib/fluent/plugin/kusto_version.rb +1 -1
- data/lib/fluent/plugin/out_kusto.rb +65 -5
- data/test/plugin/test_out_kusto_resolve_table_name.rb +320 -0
- metadata +5 -10
- data/test/plugin/test_mi_tokenprovider.rb +0 -165
- data/test/plugin/test_wif_tokenprovider.rb +0 -145
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3f5182c8d42b84b2e41dfb92f5bd01be66e3d2ebefb6171182c16b464d4af27e
|
|
4
|
+
data.tar.gz: 81ec9a5a9ae6d3d41daa76c1334104e6c7987655e6c81df158081233394b4340
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: f3e7620f5ec7327efb52897436c4f7a40bb0f0a7d9ac3ee7f0f5f39eccf9260c03e0d6b7211f610e7b85eb9de91ffff05f16008c3636f11ef1f135857b8d72b5
|
|
7
|
+
data.tar.gz: 2cef3305e66b2062f2d5be1b1fac4692ddf8a550885ab9a04c77bc9f861f56e45f74ce3232a0c6860d88fa1c0817234493a1ce3b813af891291835d07eff20cf
|
data/README.md
CHANGED
|
@@ -42,7 +42,7 @@ This plugin allows you to send data from Fluentd to Azure Data Explorer (Kusto)
|
|
|
42
42
|
### RubyGems
|
|
43
43
|
|
|
44
44
|
```sh
|
|
45
|
-
$ gem install fluent-plugin-kusto
|
|
45
|
+
$ gem install fluent-plugin-kusto
|
|
46
46
|
```
|
|
47
47
|
|
|
48
48
|
### Bundler
|
|
@@ -50,11 +50,9 @@ $ gem install fluent-plugin-kusto --pre
|
|
|
50
50
|
Add the following line to your Gemfile:
|
|
51
51
|
|
|
52
52
|
```ruby
|
|
53
|
-
gem "fluent-plugin-kusto", "~>
|
|
53
|
+
gem "fluent-plugin-kusto", "~> 1.1.1.beta"
|
|
54
54
|
```
|
|
55
55
|
|
|
56
|
-
**Note:** This is a beta release. Use the `--pre` flag with gem install or specify the beta version in your Gemfile.
|
|
57
|
-
|
|
58
56
|
And then execute:
|
|
59
57
|
|
|
60
58
|
```sh
|
|
@@ -244,6 +242,60 @@ This approach provides flexibility to transform the generic 3-column format into
|
|
|
244
242
|
| `azure_cloud` | Azure cloud environment: `AzureCloud`, `AzureChinaCloud`, `AzureUSGovernmentCloud`, `AzureGermanCloud` | `AzureCloud` |
|
|
245
243
|
| `logger_path` | File path for plugin logs. If not set, logs to stdout. | stdout |
|
|
246
244
|
|
|
245
|
+
## Dynamic Table Name Resolution
|
|
246
|
+
|
|
247
|
+
The plugin supports dynamic table name resolution using placeholders in the `table_name` parameter. This allows you to route logs to different tables based on the Fluentd tag.
|
|
248
|
+
|
|
249
|
+
### Supported Placeholders
|
|
250
|
+
|
|
251
|
+
| Placeholder | Description | Example |
|
|
252
|
+
|------------|-------------|---------|
|
|
253
|
+
| `${tag}` | Full tag name (dots → underscores) | `app.orders.created` → `app_orders_created` |
|
|
254
|
+
| `${tag_parts[N]}` | Nth part of tag (0-indexed) | `${tag_parts[1]}` with `app.orders.created` → `orders` |
|
|
255
|
+
| `${tag_prefix[N]}` | First N parts joined | `${tag_prefix[2]}` with `app.orders.created` → `app_orders` |
|
|
256
|
+
| `${tag_suffix[N]}` | Last N parts joined | `${tag_suffix[2]}` with `app.orders.created` → `orders_created` |
|
|
257
|
+
|
|
258
|
+
### Usage Examples
|
|
259
|
+
|
|
260
|
+
**Route by tag part:**
|
|
261
|
+
```conf
|
|
262
|
+
<match custom.**>
|
|
263
|
+
@type kusto
|
|
264
|
+
table_name ${tag_parts[1]}
|
|
265
|
+
# custom.orders.created → orders table
|
|
266
|
+
# custom.users.signup → users table
|
|
267
|
+
</match>
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Mixed static and dynamic:**
|
|
271
|
+
```conf
|
|
272
|
+
<match app.**>
|
|
273
|
+
@type kusto
|
|
274
|
+
table_name logs_${tag_parts[1]}
|
|
275
|
+
# app.orders → logs_orders table
|
|
276
|
+
</match>
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
**Multiple placeholders:**
|
|
280
|
+
```conf
|
|
281
|
+
<match **>
|
|
282
|
+
@type kusto
|
|
283
|
+
table_name ${tag_parts[0]}_${tag_parts[1]}_events
|
|
284
|
+
# production.api.requests → production_api_events table
|
|
285
|
+
</match>
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
**Notes:**
|
|
289
|
+
- All special characters are automatically converted to underscores
|
|
290
|
+
- Consecutive underscores are collapsed to single underscores
|
|
291
|
+
- Static table names (without placeholders) continue to work as before
|
|
292
|
+
- Placeholders are resolved at ingestion time based on the event tag
|
|
293
|
+
|
|
294
|
+
**Important Limitations:**
|
|
295
|
+
- ⚠️ **Delayed commits not supported with dynamic table names**: The `delayed` commit feature is currently incompatible with placeholder-based table names. When using dynamic table names, ensure `delayed` is set to `false` (the default).
|
|
296
|
+
- ⚠️ **Validate placeholder patterns**: Ensure your placeholder patterns always resolve to non-empty, valid table names for all expected tags. For example, accessing a tag part index that doesn't exist (e.g., `${tag_parts[5]}` for tag `app.orders`) will resolve to `"unknown"` as a fallback.
|
|
297
|
+
- ⚠️ **Empty or nil tags**: If a tag is empty or nil when using placeholders, the plugin will use `"unknown"` as a fallback to prevent ingestion failures.
|
|
298
|
+
|
|
247
299
|
### Buffer Configuration (buffered mode only)
|
|
248
300
|
| Key | Description | Default |
|
|
249
301
|
| --- | ----------- | ------- |
|
|
@@ -393,7 +445,13 @@ This diagram shows the main components and data flow for the plugin, including c
|
|
|
393
445
|
|
|
394
446
|
## Release Notes
|
|
395
447
|
|
|
396
|
-
###
|
|
448
|
+
### v1.1.1.beta (Latest)
|
|
449
|
+
- **Dynamic table name resolution** - Added support for placeholder-based table name routing using `${tag}`, `${tag_parts[N]}`, `${tag_prefix[N]}`, and `${tag_suffix[N]}`
|
|
450
|
+
- **Enhanced flexibility** - Route logs to different tables based on Fluentd tags without code changes
|
|
451
|
+
- **Backwards compatible** - Static table names continue to work as before
|
|
452
|
+
|
|
453
|
+
### v1.0.0
|
|
454
|
+
- **Production-ready release** - Stable version with comprehensive testing
|
|
397
455
|
- **Fixed critical authentication initialization bugs** - Resolved `NameError` in ManagedIdentityTokenProvider and WorkloadIdentityTokenProvider
|
|
398
456
|
- **Added comprehensive unit test coverage** - New test suites for authentication providers with 14 test cases and 45+ assertions
|
|
399
457
|
- **Improved E2E test reliability** - Enhanced timeout configurations to handle Azure Kusto ingestion delays (480s-600s timeouts)
|
|
@@ -63,6 +63,7 @@ module Fluent
|
|
|
63
63
|
validate_buffer_config(conf)
|
|
64
64
|
validate_delayed_config
|
|
65
65
|
validate_required_params
|
|
66
|
+
@table_name_template = table_name
|
|
66
67
|
end
|
|
67
68
|
|
|
68
69
|
def start
|
|
@@ -70,7 +71,7 @@ module Fluent
|
|
|
70
71
|
super
|
|
71
72
|
setup_outconfiguration
|
|
72
73
|
setup_ingester_and_logger
|
|
73
|
-
@
|
|
74
|
+
@table_name_template = @outconfiguration&.table_name
|
|
74
75
|
@database_name = @outconfiguration&.database_name
|
|
75
76
|
@shutdown_called = false
|
|
76
77
|
@deferred_threads = []
|
|
@@ -160,12 +161,13 @@ module Fluent
|
|
|
160
161
|
end
|
|
161
162
|
|
|
162
163
|
def process(tag, es)
|
|
164
|
+
resolved_table = resolve_table_name(tag)
|
|
163
165
|
es.each do |time, record|
|
|
164
166
|
formatted = format(tag, time, record).encode('UTF-8', invalid: :replace, undef: :replace, replace: '_')
|
|
165
167
|
safe_tag = tag.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '_').gsub(/[^0-9A-Za-z.-]/,
|
|
166
168
|
'_')
|
|
167
169
|
blob_name = "fluentd_event_#{safe_tag}.json"
|
|
168
|
-
@ingester.upload_data_to_blob_and_queue(formatted, blob_name, @database_name,
|
|
170
|
+
@ingester.upload_data_to_blob_and_queue(formatted, blob_name, @database_name, resolved_table,
|
|
169
171
|
compression_enabled, @ingestion_mapping_reference)
|
|
170
172
|
rescue StandardError => e
|
|
171
173
|
@logger&.error("Failed to ingest event to Kusto: #{e}\nEvent skipped: #{record.inspect}\n#{e.backtrace.join("\n")}")
|
|
@@ -178,6 +180,7 @@ module Fluent
|
|
|
178
180
|
worker_id = Fluent::Engine.worker_id
|
|
179
181
|
raw_data = chunk.read
|
|
180
182
|
tag = extract_tag_from_metadata(chunk.metadata)
|
|
183
|
+
resolved_table = resolve_table_name(tag)
|
|
181
184
|
safe_tag = tag.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '_').gsub(/[^0-9A-Za-z.-]/,
|
|
182
185
|
'_')
|
|
183
186
|
unique_id = chunk.unique_id
|
|
@@ -185,7 +188,7 @@ module Fluent
|
|
|
185
188
|
blob_name = "fluentd_event_worker#{worker_id}_#{safe_tag}_#{dump_unique_id_hex(unique_id)}#{ext}"
|
|
186
189
|
data_to_upload = compression_enabled ? compress_data(raw_data) : raw_data
|
|
187
190
|
begin
|
|
188
|
-
@ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name,
|
|
191
|
+
@ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name, resolved_table,
|
|
189
192
|
compression_enabled, @ingestion_mapping_reference)
|
|
190
193
|
rescue StandardError => e
|
|
191
194
|
handle_kusto_error(e, unique_id)
|
|
@@ -208,6 +211,7 @@ module Fluent
|
|
|
208
211
|
def try_write(chunk)
|
|
209
212
|
@deferred_threads ||= []
|
|
210
213
|
tag = extract_tag_from_metadata(chunk.metadata)
|
|
214
|
+
resolved_table = resolve_table_name(tag)
|
|
211
215
|
safe_tag = tag.to_s.encode('UTF-8', invalid: :replace, undef: :replace, replace: '_').gsub(/[^0-9A-Za-z.-]/,
|
|
212
216
|
'_')
|
|
213
217
|
chunk_id = dump_unique_id_hex(chunk.unique_id)
|
|
@@ -225,7 +229,7 @@ module Fluent
|
|
|
225
229
|
row_count = records.size
|
|
226
230
|
data_to_upload = compression_enabled ? compress_data(updated_raw_data) : updated_raw_data
|
|
227
231
|
begin
|
|
228
|
-
@ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name,
|
|
232
|
+
@ingester.upload_data_to_blob_and_queue(data_to_upload, blob_name, @database_name, resolved_table,
|
|
229
233
|
compression_enabled, @ingestion_mapping_reference)
|
|
230
234
|
if @shutdown_called || !@delayed
|
|
231
235
|
commit_write(chunk.unique_id)
|
|
@@ -287,9 +291,10 @@ module Fluent
|
|
|
287
291
|
|
|
288
292
|
def check_data_on_server(chunk_id, row_count)
|
|
289
293
|
# Query Kusto to verify chunk ingestion
|
|
294
|
+
# Note: For dynamic table names, this uses the template name which may not work with placeholders
|
|
290
295
|
begin
|
|
291
296
|
# Sanitize inputs to prevent injection attacks
|
|
292
|
-
safe_table_name = @
|
|
297
|
+
safe_table_name = @table_name_template.to_s.gsub(/[^a-zA-Z0-9_${}]/, '')
|
|
293
298
|
safe_chunk_id = chunk_id.to_s.gsub(/[^a-zA-Z0-9_-]/, '')
|
|
294
299
|
query = "#{safe_table_name} | extend record_dynamic = parse_json(record) | where record_dynamic.chunk_id == '#{safe_chunk_id}' | count"
|
|
295
300
|
result = run_kusto_api_query(query, @outconfiguration.kusto_endpoint, @ingester.token_provider,
|
|
@@ -340,6 +345,61 @@ module Fluent
|
|
|
340
345
|
super
|
|
341
346
|
end
|
|
342
347
|
|
|
348
|
+
def resolve_table_name(tag)
|
|
349
|
+
# Resolve table name from template with placeholders
|
|
350
|
+
if @table_name_template.nil? || @table_name_template.empty?
|
|
351
|
+
@logger&.error('Table name template is nil or empty')
|
|
352
|
+
raise Fluent::ConfigError, 'table_name must be set and non-empty'
|
|
353
|
+
end
|
|
354
|
+
|
|
355
|
+
return @table_name_template unless @table_name_template.include?('${')
|
|
356
|
+
|
|
357
|
+
tag_str = tag.to_s
|
|
358
|
+
|
|
359
|
+
# Validate tag is not empty when using placeholders
|
|
360
|
+
if tag_str.empty?
|
|
361
|
+
@logger&.warn("Tag is empty when resolving dynamic table name, using template as fallback: #{@table_name_template}")
|
|
362
|
+
return @table_name_template.gsub(/\$\{[^}]+\}/, 'unknown').gsub(/[^0-9A-Za-z_]/, '_')
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
tag_parts = tag_str.split('.')
|
|
366
|
+
result = @table_name_template.dup
|
|
367
|
+
|
|
368
|
+
# Replace ${tag} with full tag (dots converted to underscores)
|
|
369
|
+
result = result.gsub('${tag}', tag_str.gsub('.', '_'))
|
|
370
|
+
|
|
371
|
+
# Replace ${tag_parts[N]} with Nth part
|
|
372
|
+
result = result.gsub(/\$\{tag_parts\[(\d+)\]\}/) do
|
|
373
|
+
index = ::Regexp.last_match(1).to_i
|
|
374
|
+
tag_parts[index] || 'unknown'
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# Replace ${tag_prefix[N]} with first N parts
|
|
378
|
+
result = result.gsub(/\$\{tag_prefix\[(\d+)\]\}/) do
|
|
379
|
+
count = ::Regexp.last_match(1).to_i
|
|
380
|
+
parts = tag_parts.take(count)
|
|
381
|
+
parts.empty? ? 'unknown' : parts.join('_')
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
# Replace ${tag_suffix[N]} with last N parts
|
|
385
|
+
result = result.gsub(/\$\{tag_suffix\[(\d+)\]\}/) do
|
|
386
|
+
count = ::Regexp.last_match(1).to_i
|
|
387
|
+
parts = tag_parts.last(count)
|
|
388
|
+
parts.empty? ? 'unknown' : parts.join('_')
|
|
389
|
+
end
|
|
390
|
+
|
|
391
|
+
# Sanitize: replace special characters with underscores and collapse consecutive underscores
|
|
392
|
+
sanitized = result.gsub(/[^0-9A-Za-z_]/, '_').gsub(/_+/, '_')
|
|
393
|
+
|
|
394
|
+
# Final validation: ensure we don't have an empty table name
|
|
395
|
+
if sanitized.empty? || sanitized == '_'
|
|
396
|
+
@logger&.error("Resolved table name is empty or invalid for tag '#{tag}' with template '#{@table_name_template}'")
|
|
397
|
+
raise Fluent::ConfigError, "table_name resolved to empty or invalid value for tag '#{tag}'"
|
|
398
|
+
end
|
|
399
|
+
|
|
400
|
+
sanitized
|
|
401
|
+
end
|
|
402
|
+
|
|
343
403
|
private
|
|
344
404
|
|
|
345
405
|
def validate_buffer_config(conf)
|
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
# rubocop:disable all
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'ostruct'
|
|
5
|
+
require_relative '../helper'
|
|
6
|
+
require 'fluent/test/driver/output'
|
|
7
|
+
require 'fluent/plugin/out_kusto'
|
|
8
|
+
require 'mocha/test_unit'
|
|
9
|
+
|
|
10
|
+
class KustoOutputResolveTableNameTest < Test::Unit::TestCase
|
|
11
|
+
setup do
|
|
12
|
+
Fluent::Test.setup
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def create_driver(table_name = 'testtable')
|
|
16
|
+
Fluent::Test::Driver::Output.new(Fluent::Plugin::KustoOutput).configure(<<-CONF)
|
|
17
|
+
@type kusto
|
|
18
|
+
endpoint https://example.kusto.windows.net
|
|
19
|
+
database_name testdb
|
|
20
|
+
table_name #{table_name}
|
|
21
|
+
client_id dummy-client-id
|
|
22
|
+
client_secret dummy-secret
|
|
23
|
+
tenant_id dummy-tenant
|
|
24
|
+
buffered true
|
|
25
|
+
auth_type aad
|
|
26
|
+
CONF
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def logger_stub
|
|
30
|
+
m = mock
|
|
31
|
+
m.stubs(:debug)
|
|
32
|
+
m.stubs(:error)
|
|
33
|
+
m.stubs(:info)
|
|
34
|
+
m.stubs(:warn)
|
|
35
|
+
m
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def ingester_stub
|
|
39
|
+
m = mock
|
|
40
|
+
m.stubs(:upload_data_to_blob_and_queue)
|
|
41
|
+
m
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def set_mocks(driver, ingester: nil, logger: nil)
|
|
45
|
+
driver.instance.instance_variable_set(:@ingester, ingester) if ingester
|
|
46
|
+
driver.instance.instance_variable_set(:@logger, logger) if logger
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def chunk_stub(data: 'testdata', tag: 'test.tag', unique_id: 'uniqueid'.b, metadata: nil)
|
|
50
|
+
c = mock
|
|
51
|
+
c.stubs(:read).returns(data)
|
|
52
|
+
c.stubs(:metadata).returns(metadata || OpenStruct.new(tag: tag))
|
|
53
|
+
c.stubs(:unique_id).returns(unique_id)
|
|
54
|
+
c
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# ==========================================
|
|
58
|
+
# resolve_table_name method tests
|
|
59
|
+
# ==========================================
|
|
60
|
+
|
|
61
|
+
test 'resolve_table_name returns static table name when no placeholders' do
|
|
62
|
+
driver = create_driver('MyStaticTable')
|
|
63
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
64
|
+
assert_equal 'MyStaticTable', result
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
test 'resolve_table_name replaces ${tag} with full tag' do
|
|
68
|
+
driver = create_driver('${tag}')
|
|
69
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
70
|
+
assert_equal 'app_orders_created', result # dots replaced with underscores
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
test 'resolve_table_name replaces ${tag_parts[0]} with first part' do
|
|
74
|
+
driver = create_driver('${tag_parts[0]}')
|
|
75
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
76
|
+
assert_equal 'app', result
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
test 'resolve_table_name replaces ${tag_parts[1]} with second part' do
|
|
80
|
+
driver = create_driver('${tag_parts[1]}')
|
|
81
|
+
result = driver.instance.send(:resolve_table_name, 'custom.orders.created')
|
|
82
|
+
assert_equal 'orders', result
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
test 'resolve_table_name replaces ${tag_parts[2]} with third part' do
|
|
86
|
+
driver = create_driver('${tag_parts[2]}')
|
|
87
|
+
result = driver.instance.send(:resolve_table_name, 'custom.orders.created')
|
|
88
|
+
assert_equal 'created', result
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
test 'resolve_table_name returns empty string for out-of-bounds tag_parts index' do
|
|
92
|
+
driver = create_driver('${tag_parts[5]}')
|
|
93
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders')
|
|
94
|
+
assert_equal '', result
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
test 'resolve_table_name replaces ${tag_prefix[1]} with first part' do
|
|
98
|
+
driver = create_driver('${tag_prefix[1]}')
|
|
99
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
100
|
+
assert_equal 'app', result
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
test 'resolve_table_name replaces ${tag_prefix[2]} with first two parts' do
|
|
104
|
+
driver = create_driver('${tag_prefix[2]}')
|
|
105
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
106
|
+
assert_equal 'app_orders', result # dot replaced with underscore
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
test 'resolve_table_name replaces ${tag_suffix[1]} with last part' do
|
|
110
|
+
driver = create_driver('${tag_suffix[1]}')
|
|
111
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
112
|
+
assert_equal 'created', result
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
test 'resolve_table_name replaces ${tag_suffix[2]} with last two parts' do
|
|
116
|
+
driver = create_driver('${tag_suffix[2]}')
|
|
117
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.created')
|
|
118
|
+
assert_equal 'orders_created', result # dot replaced with underscore
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
test 'resolve_table_name handles mixed static and placeholder' do
|
|
122
|
+
driver = create_driver('prefix_${tag_parts[1]}_suffix')
|
|
123
|
+
result = driver.instance.send(:resolve_table_name, 'custom.orders.events')
|
|
124
|
+
assert_equal 'prefix_orders_suffix', result
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
test 'resolve_table_name handles multiple placeholders' do
|
|
128
|
+
driver = create_driver('${tag_parts[0]}_${tag_parts[1]}')
|
|
129
|
+
result = driver.instance.send(:resolve_table_name, 'env.production.logs')
|
|
130
|
+
assert_equal 'env_production', result
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
test 'resolve_table_name sanitizes special characters' do
|
|
134
|
+
driver = create_driver('${tag}')
|
|
135
|
+
result = driver.instance.send(:resolve_table_name, 'app-name.service:type.logs')
|
|
136
|
+
assert_equal 'app_name_service_type_logs', result # special chars replaced with underscores
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
test 'resolve_table_name handles single-part tag' do
|
|
140
|
+
driver = create_driver('${tag_parts[0]}')
|
|
141
|
+
result = driver.instance.send(:resolve_table_name, 'singletag')
|
|
142
|
+
assert_equal 'singletag', result
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
test 'resolve_table_name returns empty for tag_parts on single-part tag with index > 0' do
|
|
146
|
+
driver = create_driver('${tag_parts[1]}')
|
|
147
|
+
result = driver.instance.send(:resolve_table_name, 'singletag')
|
|
148
|
+
assert_equal '', result
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
test 'resolve_table_name handles empty tag' do
|
|
152
|
+
driver = create_driver('${tag}')
|
|
153
|
+
result = driver.instance.send(:resolve_table_name, '')
|
|
154
|
+
assert_equal '', result
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
test 'resolve_table_name handles nil tag' do
|
|
158
|
+
driver = create_driver('${tag}')
|
|
159
|
+
result = driver.instance.send(:resolve_table_name, nil)
|
|
160
|
+
assert_equal '', result
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# ==========================================
|
|
164
|
+
# Integration tests with write method
|
|
165
|
+
# ==========================================
|
|
166
|
+
|
|
167
|
+
test 'write uses resolved table name with tag_parts placeholder' do
|
|
168
|
+
driver = create_driver('${tag_parts[1]}')
|
|
169
|
+
ingester_mock = mock
|
|
170
|
+
# Verify the table name passed to ingester is 'orders' (from tag custom.orders.events)
|
|
171
|
+
ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
172
|
+
assert_equal 'testdb', db
|
|
173
|
+
assert_equal 'orders', table
|
|
174
|
+
true
|
|
175
|
+
end
|
|
176
|
+
set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
|
|
177
|
+
chunk = chunk_stub(tag: 'custom.orders.events')
|
|
178
|
+
assert_nothing_raised { driver.instance.write(chunk) }
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
test 'write uses resolved table name with tag placeholder' do
|
|
182
|
+
driver = create_driver('${tag}')
|
|
183
|
+
ingester_mock = mock
|
|
184
|
+
ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
185
|
+
assert_equal 'testdb', db
|
|
186
|
+
assert_equal 'app_service_logs', table # dots converted to underscores
|
|
187
|
+
true
|
|
188
|
+
end
|
|
189
|
+
set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
|
|
190
|
+
chunk = chunk_stub(tag: 'app.service.logs')
|
|
191
|
+
assert_nothing_raised { driver.instance.write(chunk) }
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
test 'write uses static table name when no placeholders' do
|
|
195
|
+
driver = create_driver('MyStaticTable')
|
|
196
|
+
ingester_mock = mock
|
|
197
|
+
ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
198
|
+
assert_equal 'testdb', db
|
|
199
|
+
assert_equal 'MyStaticTable', table
|
|
200
|
+
true
|
|
201
|
+
end
|
|
202
|
+
set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
|
|
203
|
+
chunk = chunk_stub(tag: 'any.tag.here')
|
|
204
|
+
assert_nothing_raised { driver.instance.write(chunk) }
|
|
205
|
+
end
|
|
206
|
+
|
|
207
|
+
test 'write routes different tags to different tables' do
|
|
208
|
+
driver = create_driver('${tag_parts[1]}')
|
|
209
|
+
ingester_mock = mock
|
|
210
|
+
|
|
211
|
+
# First call should use 'orders' table
|
|
212
|
+
ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
213
|
+
assert_equal 'orders', table
|
|
214
|
+
true
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
|
|
218
|
+
chunk1 = chunk_stub(tag: 'custom.orders.created')
|
|
219
|
+
assert_nothing_raised { driver.instance.write(chunk1) }
|
|
220
|
+
|
|
221
|
+
# Second call should use 'users' table
|
|
222
|
+
ingester_mock2 = mock
|
|
223
|
+
ingester_mock2.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
224
|
+
assert_equal 'users', table
|
|
225
|
+
true
|
|
226
|
+
end
|
|
227
|
+
set_mocks(driver, ingester: ingester_mock2, logger: logger_stub)
|
|
228
|
+
chunk2 = chunk_stub(tag: 'custom.users.signup')
|
|
229
|
+
assert_nothing_raised { driver.instance.write(chunk2) }
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
# ==========================================
|
|
233
|
+
# Edge cases and special characters
|
|
234
|
+
# ==========================================
|
|
235
|
+
|
|
236
|
+
test 'resolve_table_name handles tag with numbers' do
|
|
237
|
+
driver = create_driver('${tag_parts[1]}')
|
|
238
|
+
result = driver.instance.send(:resolve_table_name, 'app.table123.logs')
|
|
239
|
+
assert_equal 'table123', result
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
test 'resolve_table_name handles tag with underscores' do
|
|
243
|
+
driver = create_driver('${tag_parts[1]}')
|
|
244
|
+
result = driver.instance.send(:resolve_table_name, 'app.my_table.logs')
|
|
245
|
+
assert_equal 'my_table', result
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
test 'resolve_table_name preserves underscores' do
|
|
249
|
+
driver = create_driver('my_${tag_parts[1]}_table')
|
|
250
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders.logs')
|
|
251
|
+
assert_equal 'my_orders_table', result
|
|
252
|
+
end
|
|
253
|
+
|
|
254
|
+
test 'resolve_table_name handles deeply nested tags' do
|
|
255
|
+
driver = create_driver('${tag_parts[3]}')
|
|
256
|
+
result = driver.instance.send(:resolve_table_name, 'level1.level2.level3.level4.level5')
|
|
257
|
+
assert_equal 'level4', result
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
test 'resolve_table_name with tag_prefix handles more parts than exist' do
|
|
261
|
+
driver = create_driver('${tag_prefix[10]}')
|
|
262
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders')
|
|
263
|
+
assert_equal 'app_orders', result # Returns all parts when count exceeds available
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
test 'resolve_table_name with tag_suffix handles more parts than exist' do
|
|
267
|
+
driver = create_driver('${tag_suffix[10]}')
|
|
268
|
+
result = driver.instance.send(:resolve_table_name, 'app.orders')
|
|
269
|
+
assert_equal 'app_orders', result # Returns all parts when count exceeds available
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
# ==========================================
|
|
273
|
+
# Tests for try_write with dynamic table names
|
|
274
|
+
# ==========================================
|
|
275
|
+
|
|
276
|
+
test 'try_write uses resolved table name' do
|
|
277
|
+
driver = create_driver('${tag_parts[1]}')
|
|
278
|
+
driver.instance.instance_variable_set(:@delayed, false)
|
|
279
|
+
driver.instance.instance_variable_set(:@shutdown_called, false)
|
|
280
|
+
|
|
281
|
+
ingester_mock = mock
|
|
282
|
+
ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
283
|
+
assert_equal 'orders', table
|
|
284
|
+
true
|
|
285
|
+
end
|
|
286
|
+
|
|
287
|
+
set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
|
|
288
|
+
|
|
289
|
+
# Need to stub commit_write since we're in non-delayed mode
|
|
290
|
+
driver.instance.stubs(:commit_write)
|
|
291
|
+
|
|
292
|
+
chunk = chunk_stub(
|
|
293
|
+
data: '{"tag":"custom.orders","timestamp":"2024-01-01","record":{"key":"value"}}',
|
|
294
|
+
tag: 'custom.orders.events'
|
|
295
|
+
)
|
|
296
|
+
assert_nothing_raised { driver.instance.try_write(chunk) }
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# ==========================================
|
|
300
|
+
# Tests for process with dynamic table names
|
|
301
|
+
# ==========================================
|
|
302
|
+
|
|
303
|
+
test 'process uses resolved table name' do
|
|
304
|
+
driver = create_driver('${tag_parts[1]}')
|
|
305
|
+
|
|
306
|
+
ingester_mock = mock
|
|
307
|
+
ingester_mock.expects(:upload_data_to_blob_and_queue).once.with do |_data, _blob_name, db, table, _compression, _mapping|
|
|
308
|
+
assert_equal 'orders', table
|
|
309
|
+
true
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
set_mocks(driver, ingester: ingester_mock, logger: logger_stub)
|
|
313
|
+
|
|
314
|
+
# Create a simple event stream
|
|
315
|
+
es = mock
|
|
316
|
+
es.stubs(:each).yields(Time.now.to_i, {'message' => 'test'})
|
|
317
|
+
|
|
318
|
+
assert_nothing_raised { driver.instance.process('custom.orders.events', es) }
|
|
319
|
+
end
|
|
320
|
+
end
|
metadata
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: fluent-plugin-kusto
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 1.
|
|
4
|
+
version: 1.1.1.beta
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Komal Rani
|
|
8
8
|
- Kusto OSS IDC Team
|
|
9
|
-
autorequire:
|
|
10
9
|
bindir: bin
|
|
11
10
|
cert_chain: []
|
|
12
|
-
date:
|
|
11
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
13
12
|
dependencies:
|
|
14
13
|
- !ruby/object:Gem::Dependency
|
|
15
14
|
name: bundler
|
|
@@ -150,21 +149,19 @@ files:
|
|
|
150
149
|
- test/helper.rb
|
|
151
150
|
- test/plugin/test_azcli_tokenprovider.rb
|
|
152
151
|
- test/plugin/test_e2e_kusto.rb
|
|
153
|
-
- test/plugin/test_mi_tokenprovider.rb
|
|
154
152
|
- test/plugin/test_out_kusto_config.rb
|
|
155
153
|
- test/plugin/test_out_kusto_format.rb
|
|
156
154
|
- test/plugin/test_out_kusto_process.rb
|
|
155
|
+
- test/plugin/test_out_kusto_resolve_table_name.rb
|
|
157
156
|
- test/plugin/test_out_kusto_start.rb
|
|
158
157
|
- test/plugin/test_out_kusto_try_write.rb
|
|
159
158
|
- test/plugin/test_out_kusto_write.rb
|
|
160
|
-
- test/plugin/test_wif_tokenprovider.rb
|
|
161
159
|
homepage: https://github.com/Azure/azure-kusto-fluentd
|
|
162
160
|
licenses:
|
|
163
161
|
- Apache-2.0
|
|
164
162
|
metadata:
|
|
165
163
|
fluentd_plugin: 'true'
|
|
166
164
|
fluentd_group: output
|
|
167
|
-
post_install_message:
|
|
168
165
|
rdoc_options: []
|
|
169
166
|
require_paths:
|
|
170
167
|
- lib
|
|
@@ -179,19 +176,17 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
179
176
|
- !ruby/object:Gem::Version
|
|
180
177
|
version: '0'
|
|
181
178
|
requirements: []
|
|
182
|
-
rubygems_version: 3.
|
|
183
|
-
signing_key:
|
|
179
|
+
rubygems_version: 3.7.1
|
|
184
180
|
specification_version: 4
|
|
185
181
|
summary: A custom Fluentd output plugin for Azure Kusto ingestion.
|
|
186
182
|
test_files:
|
|
187
183
|
- test/helper.rb
|
|
188
184
|
- test/plugin/test_azcli_tokenprovider.rb
|
|
189
185
|
- test/plugin/test_e2e_kusto.rb
|
|
190
|
-
- test/plugin/test_mi_tokenprovider.rb
|
|
191
186
|
- test/plugin/test_out_kusto_config.rb
|
|
192
187
|
- test/plugin/test_out_kusto_format.rb
|
|
193
188
|
- test/plugin/test_out_kusto_process.rb
|
|
189
|
+
- test/plugin/test_out_kusto_resolve_table_name.rb
|
|
194
190
|
- test/plugin/test_out_kusto_start.rb
|
|
195
191
|
- test/plugin/test_out_kusto_try_write.rb
|
|
196
192
|
- test/plugin/test_out_kusto_write.rb
|
|
197
|
-
- test/plugin/test_wif_tokenprovider.rb
|
|
@@ -1,165 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test/unit'
|
|
4
|
-
require 'mocha/test_unit'
|
|
5
|
-
require_relative '../../lib/fluent/plugin/auth/mi_tokenprovider'
|
|
6
|
-
|
|
7
|
-
class DummyConfigForMI
|
|
8
|
-
attr_reader :kusto_endpoint, :managed_identity_client_id
|
|
9
|
-
|
|
10
|
-
def initialize(kusto_endpoint, managed_identity_client_id = nil)
|
|
11
|
-
@kusto_endpoint = kusto_endpoint
|
|
12
|
-
@managed_identity_client_id = managed_identity_client_id
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
def logger
|
|
16
|
-
require 'logger'
|
|
17
|
-
Logger.new($stdout)
|
|
18
|
-
end
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
class ManagedIdentityTokenProviderTest < Test::Unit::TestCase
|
|
22
|
-
def setup
|
|
23
|
-
@resource = 'https://kusto.kusto.windows.net'
|
|
24
|
-
@client_id = '074c3c54-29e2-4230-a81f-333868b8d6ca'
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
def test_initialize_with_user_managed_identity
|
|
28
|
-
config = DummyConfigForMI.new(@resource, @client_id)
|
|
29
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
30
|
-
|
|
31
|
-
# Verify instance variables are set correctly
|
|
32
|
-
assert_equal @resource, provider.instance_variable_get(:@resource)
|
|
33
|
-
assert_equal @client_id, provider.instance_variable_get(:@managed_identity_client_id)
|
|
34
|
-
assert_equal false, provider.instance_variable_get(:@use_system_assigned)
|
|
35
|
-
assert_equal true, provider.instance_variable_get(:@use_user_assigned)
|
|
36
|
-
assert_not_nil provider.instance_variable_get(:@token_acquire_url)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
def test_initialize_with_system_managed_identity
|
|
40
|
-
config = DummyConfigForMI.new(@resource, 'SYSTEM')
|
|
41
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
42
|
-
|
|
43
|
-
# Verify instance variables are set correctly for system managed identity
|
|
44
|
-
assert_equal @resource, provider.instance_variable_get(:@resource)
|
|
45
|
-
assert_equal 'SYSTEM', provider.instance_variable_get(:@managed_identity_client_id)
|
|
46
|
-
assert_equal true, provider.instance_variable_get(:@use_system_assigned)
|
|
47
|
-
assert_equal false, provider.instance_variable_get(:@use_user_assigned)
|
|
48
|
-
assert_not_nil provider.instance_variable_get(:@token_acquire_url)
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
def test_initialize_with_empty_client_id
|
|
52
|
-
config = DummyConfigForMI.new(@resource, '')
|
|
53
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
54
|
-
|
|
55
|
-
# Verify instance variables are set correctly for empty client_id
|
|
56
|
-
assert_equal @resource, provider.instance_variable_get(:@resource)
|
|
57
|
-
assert_equal '', provider.instance_variable_get(:@managed_identity_client_id)
|
|
58
|
-
assert_equal false, provider.instance_variable_get(:@use_system_assigned)
|
|
59
|
-
assert_equal false, provider.instance_variable_get(:@use_user_assigned)
|
|
60
|
-
end
|
|
61
|
-
|
|
62
|
-
def test_token_acquire_url_formation_user_managed_identity
|
|
63
|
-
config = DummyConfigForMI.new(@resource, @client_id)
|
|
64
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
65
|
-
|
|
66
|
-
token_url = provider.instance_variable_get(:@token_acquire_url)
|
|
67
|
-
expected_base = 'http://169.254.169.254/metadata/identity/oauth2/token'
|
|
68
|
-
|
|
69
|
-
assert token_url.include?(expected_base), "Token URL should contain base IMDS endpoint"
|
|
70
|
-
assert token_url.include?('resource=https%3A%2F%2Fkusto.kusto.windows.net'), "Token URL should contain encoded resource"
|
|
71
|
-
assert token_url.include?('api-version=2018-02-01'), "Token URL should contain API version"
|
|
72
|
-
assert token_url.include?("client_id=#{@client_id}"), "Token URL should contain client_id for user managed identity"
|
|
73
|
-
end
|
|
74
|
-
|
|
75
|
-
def test_token_acquire_url_formation_system_managed_identity
|
|
76
|
-
config = DummyConfigForMI.new(@resource, 'SYSTEM')
|
|
77
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
78
|
-
|
|
79
|
-
token_url = provider.instance_variable_get(:@token_acquire_url)
|
|
80
|
-
expected_base = 'http://169.254.169.254/metadata/identity/oauth2/token'
|
|
81
|
-
|
|
82
|
-
assert token_url.include?(expected_base), "Token URL should contain base IMDS endpoint"
|
|
83
|
-
assert token_url.include?('resource=https%3A%2F%2Fkusto.kusto.windows.net'), "Token URL should contain encoded resource"
|
|
84
|
-
assert token_url.include?('api-version=2018-02-01'), "Token URL should contain API version"
|
|
85
|
-
assert !token_url.include?('client_id='), "Token URL should NOT contain client_id for system managed identity"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
def test_fetch_token_success
|
|
89
|
-
config = DummyConfigForMI.new(@resource, @client_id)
|
|
90
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
91
|
-
|
|
92
|
-
# Mock successful HTTP response
|
|
93
|
-
mock_response = {
|
|
94
|
-
'access_token' => 'fake-access-token',
|
|
95
|
-
'expires_in' => 3600
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
provider.stubs(:post_token_request).returns(mock_response)
|
|
99
|
-
|
|
100
|
-
result = provider.send(:fetch_token)
|
|
101
|
-
assert_equal 'fake-access-token', result[:access_token]
|
|
102
|
-
assert_equal 3600, result[:expires_in]
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
def test_post_token_request_retries_on_failure
|
|
106
|
-
config = DummyConfigForMI.new(@resource, @client_id)
|
|
107
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
108
|
-
|
|
109
|
-
# Mock HTTP failure followed by success
|
|
110
|
-
mock_http = mock
|
|
111
|
-
mock_response_fail = mock
|
|
112
|
-
mock_response_fail.stubs(:code).returns(500)
|
|
113
|
-
mock_response_fail.stubs(:body).returns('Internal Server Error')
|
|
114
|
-
|
|
115
|
-
mock_response_success = mock
|
|
116
|
-
mock_response_success.stubs(:code).returns(200)
|
|
117
|
-
mock_response_success.stubs(:body).returns('{"access_token":"fake-token","expires_in":3600}')
|
|
118
|
-
|
|
119
|
-
mock_request = mock
|
|
120
|
-
Net::HTTP::Get.stubs(:new).returns(mock_request)
|
|
121
|
-
|
|
122
|
-
# Mock the HTTP client setup from create_http_client method
|
|
123
|
-
mock_http.stubs(:use_ssl=)
|
|
124
|
-
mock_http.stubs(:open_timeout=)
|
|
125
|
-
mock_http.stubs(:read_timeout=)
|
|
126
|
-
mock_http.stubs(:write_timeout=)
|
|
127
|
-
mock_http.expects(:request).twice.returns(mock_response_fail, mock_response_success)
|
|
128
|
-
Net::HTTP.stubs(:new).returns(mock_http)
|
|
129
|
-
|
|
130
|
-
# Stub sleep to speed up test
|
|
131
|
-
provider.stubs(:sleep)
|
|
132
|
-
|
|
133
|
-
result = provider.send(:post_token_request)
|
|
134
|
-
assert_equal 'fake-token', result['access_token']
|
|
135
|
-
end
|
|
136
|
-
|
|
137
|
-
def test_post_token_request_raises_after_max_retries
|
|
138
|
-
config = DummyConfigForMI.new(@resource, @client_id)
|
|
139
|
-
provider = ManagedIdentityTokenProvider.new(config)
|
|
140
|
-
|
|
141
|
-
# Mock HTTP failure for all attempts
|
|
142
|
-
mock_http = mock
|
|
143
|
-
mock_response_fail = mock
|
|
144
|
-
mock_response_fail.stubs(:code).returns(500)
|
|
145
|
-
mock_response_fail.stubs(:body).returns('Internal Server Error')
|
|
146
|
-
|
|
147
|
-
mock_request = mock
|
|
148
|
-
Net::HTTP::Get.stubs(:new).returns(mock_request)
|
|
149
|
-
|
|
150
|
-
# Mock the HTTP client setup from create_http_client method
|
|
151
|
-
mock_http.stubs(:use_ssl=)
|
|
152
|
-
mock_http.stubs(:open_timeout=)
|
|
153
|
-
mock_http.stubs(:read_timeout=)
|
|
154
|
-
mock_http.stubs(:write_timeout=)
|
|
155
|
-
mock_http.stubs(:request).returns(mock_response_fail)
|
|
156
|
-
Net::HTTP.stubs(:new).returns(mock_http)
|
|
157
|
-
|
|
158
|
-
# Stub sleep to speed up test
|
|
159
|
-
provider.stubs(:sleep)
|
|
160
|
-
|
|
161
|
-
assert_raise(RuntimeError, 'Failed to get managed identity token after 2 attempts.') do
|
|
162
|
-
provider.send(:post_token_request)
|
|
163
|
-
end
|
|
164
|
-
end
|
|
165
|
-
end
|
|
@@ -1,145 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require 'test/unit'
|
|
4
|
-
require 'mocha/test_unit'
|
|
5
|
-
require_relative '../../lib/fluent/plugin/auth/wif_tokenprovider'
|
|
6
|
-
|
|
7
|
-
class DummyConfigForWIF
|
|
8
|
-
attr_reader :kusto_endpoint, :workload_identity_client_id, :workload_identity_tenant_id, :workload_identity_token_file_path
|
|
9
|
-
|
|
10
|
-
def initialize(kusto_endpoint, client_id, tenant_id, token_file_path = nil)
|
|
11
|
-
@kusto_endpoint = kusto_endpoint
|
|
12
|
-
@workload_identity_client_id = client_id
|
|
13
|
-
@workload_identity_tenant_id = tenant_id
|
|
14
|
-
@workload_identity_token_file_path = token_file_path
|
|
15
|
-
end
|
|
16
|
-
|
|
17
|
-
def logger
|
|
18
|
-
require 'logger'
|
|
19
|
-
Logger.new($stdout)
|
|
20
|
-
end
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
class WorkloadIdentityTest < Test::Unit::TestCase
|
|
24
|
-
def setup
|
|
25
|
-
@resource = 'https://kusto.kusto.windows.net'
|
|
26
|
-
@client_id = '074c3c54-29e2-4230-a81f-333868b8d6ca'
|
|
27
|
-
@tenant_id = '12345678-1234-1234-1234-123456789abc'
|
|
28
|
-
@token_file = '/tmp/test-token'
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
def test_initialize_with_custom_token_file
|
|
32
|
-
config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
|
|
33
|
-
provider = WorkloadIdentity.new(config)
|
|
34
|
-
|
|
35
|
-
# Verify instance variables are set correctly
|
|
36
|
-
assert_equal @resource, provider.instance_variable_get(:@kusto_endpoint)
|
|
37
|
-
assert_equal @client_id, provider.instance_variable_get(:@client_id)
|
|
38
|
-
assert_equal @tenant_id, provider.instance_variable_get(:@tenant_id)
|
|
39
|
-
assert_equal @token_file, provider.instance_variable_get(:@token_file)
|
|
40
|
-
assert_equal "#{@resource}/.default", provider.instance_variable_get(:@scope)
|
|
41
|
-
end
|
|
42
|
-
|
|
43
|
-
def test_initialize_with_default_token_file
|
|
44
|
-
config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, nil)
|
|
45
|
-
provider = WorkloadIdentity.new(config)
|
|
46
|
-
|
|
47
|
-
# Verify default token file is used
|
|
48
|
-
expected_default = '/var/run/secrets/azure/tokens/azure-identity-token'
|
|
49
|
-
assert_equal expected_default, provider.instance_variable_get(:@token_file)
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
def test_fetch_token_success
|
|
53
|
-
config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
|
|
54
|
-
provider = WorkloadIdentity.new(config)
|
|
55
|
-
|
|
56
|
-
# Mock successful token acquisition
|
|
57
|
-
mock_response = {
|
|
58
|
-
'access_token' => 'wif-access-token',
|
|
59
|
-
'expires_in' => 7200
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
provider.stubs(:acquire_workload_identity_token).returns(mock_response)
|
|
63
|
-
|
|
64
|
-
result = provider.send(:fetch_token)
|
|
65
|
-
assert_equal 'wif-access-token', result[:access_token]
|
|
66
|
-
assert_equal 7200, result[:expires_in]
|
|
67
|
-
end
|
|
68
|
-
|
|
69
|
-
def test_acquire_workload_identity_token_success
|
|
70
|
-
config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
|
|
71
|
-
provider = WorkloadIdentity.new(config)
|
|
72
|
-
|
|
73
|
-
# Mock file read and HTTP request
|
|
74
|
-
File.stubs(:read).with(@token_file).returns('fake-oidc-token')
|
|
75
|
-
|
|
76
|
-
mock_response = mock
|
|
77
|
-
mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
|
|
78
|
-
mock_response.stubs(:body).returns('{"access_token":"wif-token","expires_in":7200}')
|
|
79
|
-
|
|
80
|
-
mock_http = mock
|
|
81
|
-
mock_http.expects(:use_ssl=).with(true)
|
|
82
|
-
mock_http.expects(:open_timeout=).with(10)
|
|
83
|
-
mock_http.expects(:read_timeout=).with(30)
|
|
84
|
-
mock_http.expects(:write_timeout=).with(10)
|
|
85
|
-
mock_http.expects(:request).returns(mock_response)
|
|
86
|
-
|
|
87
|
-
Net::HTTP.stubs(:new).returns(mock_http)
|
|
88
|
-
|
|
89
|
-
result = provider.send(:acquire_workload_identity_token)
|
|
90
|
-
assert_equal 'wif-token', result['access_token']
|
|
91
|
-
assert_equal 7200, result['expires_in']
|
|
92
|
-
end
|
|
93
|
-
|
|
94
|
-
def test_acquire_workload_identity_token_failure
|
|
95
|
-
config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
|
|
96
|
-
provider = WorkloadIdentity.new(config)
|
|
97
|
-
|
|
98
|
-
# Mock file read and HTTP request failure
|
|
99
|
-
File.stubs(:read).with(@token_file).returns('fake-oidc-token')
|
|
100
|
-
|
|
101
|
-
mock_response = mock
|
|
102
|
-
mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(false)
|
|
103
|
-
mock_response.stubs(:code).returns(400)
|
|
104
|
-
mock_response.stubs(:body).returns('Bad Request')
|
|
105
|
-
|
|
106
|
-
mock_http = mock
|
|
107
|
-
mock_http.expects(:use_ssl=).with(true)
|
|
108
|
-
mock_http.expects(:open_timeout=).with(10)
|
|
109
|
-
mock_http.expects(:read_timeout=).with(30)
|
|
110
|
-
mock_http.expects(:write_timeout=).with(10)
|
|
111
|
-
mock_http.expects(:request).returns(mock_response)
|
|
112
|
-
|
|
113
|
-
Net::HTTP.stubs(:new).returns(mock_http)
|
|
114
|
-
|
|
115
|
-
assert_raise(RuntimeError, 'Failed to get access token: 400 Bad Request') do
|
|
116
|
-
provider.send(:acquire_workload_identity_token)
|
|
117
|
-
end
|
|
118
|
-
end
|
|
119
|
-
|
|
120
|
-
def test_oauth2_endpoint_formation
|
|
121
|
-
config = DummyConfigForWIF.new(@resource, @client_id, @tenant_id, @token_file)
|
|
122
|
-
provider = WorkloadIdentity.new(config)
|
|
123
|
-
|
|
124
|
-
# Test that the endpoint is formatted correctly with tenant_id
|
|
125
|
-
File.stubs(:read).with(@token_file).returns('fake-oidc-token')
|
|
126
|
-
|
|
127
|
-
# Mock the rest of the HTTP request to avoid actual network call
|
|
128
|
-
mock_response = mock
|
|
129
|
-
mock_response.stubs(:is_a?).with(Net::HTTPSuccess).returns(true)
|
|
130
|
-
mock_response.stubs(:body).returns('{"access_token":"test","expires_in":3600}')
|
|
131
|
-
|
|
132
|
-
mock_http = mock
|
|
133
|
-
mock_http.expects(:use_ssl=).with(true)
|
|
134
|
-
mock_http.expects(:open_timeout=).with(10)
|
|
135
|
-
mock_http.expects(:read_timeout=).with(30)
|
|
136
|
-
mock_http.expects(:write_timeout=).with(10)
|
|
137
|
-
mock_http.expects(:request).returns(mock_response)
|
|
138
|
-
|
|
139
|
-
Net::HTTP.stubs(:new).returns(mock_http)
|
|
140
|
-
|
|
141
|
-
# Just verify it doesn't crash - the important thing is that setup_config was called
|
|
142
|
-
result = provider.send(:acquire_workload_identity_token)
|
|
143
|
-
assert_equal 'test', result['access_token']
|
|
144
|
-
end
|
|
145
|
-
end
|