aws_advanced_ruby_driver_wrapper 1.0.1 → 1.1.0

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.
Files changed (81) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +32 -2
  3. data/README.md +115 -6
  4. data/THIRD-PARTY-LICENSES +70 -11
  5. data/aws_advanced_ruby_driver_wrapper.gemspec +9 -0
  6. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +17 -3
  7. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_trilogy_adapter.rb +80 -0
  8. data/lib/aws_advanced_ruby_driver_wrapper/active_record/type_adapter_alias.rb +1 -1
  9. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +43 -0
  10. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +11 -4
  11. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql2_driver_dialect.rb +129 -0
  12. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +9 -91
  13. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +18 -0
  14. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/trilogy_driver_dialect.rb +118 -0
  15. data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +16 -0
  16. data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +10 -5
  17. data/lib/aws_advanced_ruby_driver_wrapper/errors/trilogy_error_handler.rb +127 -0
  18. data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +1 -0
  19. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +1 -0
  20. data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +27 -9
  21. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +115 -7
  22. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/drop_blue_connections_marker.rb +84 -0
  23. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +3 -1
  24. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +7 -0
  25. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +68 -41
  26. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +4 -2
  27. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +5 -3
  28. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +7 -1
  29. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +1 -1
  30. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +6 -13
  31. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +90 -3
  32. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +11 -1
  33. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +4 -1
  34. data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +14 -3
  35. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +68 -3
  36. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/base_saml_auth_plugin.rb +266 -0
  37. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/federated_auth_plugin.rb +134 -0
  38. data/lib/aws_advanced_ruby_driver_wrapper/plugins/federated_auth/okta_auth_plugin.rb +113 -0
  39. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +30 -9
  40. data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +16 -2
  41. data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +4 -1
  42. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +6 -3
  43. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +13 -4
  44. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +29 -6
  45. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_mysql.sql +108 -0
  46. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql/trigger_pg.sql +54 -0
  47. data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +14 -0
  48. data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +41 -23
  49. data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +63 -1
  50. data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +3 -0
  51. data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +20 -7
  52. data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +34 -8
  53. data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +1 -1
  54. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +80 -29
  55. data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +10 -2
  56. data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +4 -1
  57. data/lib/aws_advanced_ruby_driver_wrapper/trilogy.rb +167 -0
  58. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +1 -1
  59. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +25 -15
  60. data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +2 -2
  61. data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +13 -4
  62. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +15 -31
  63. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +55 -76
  64. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_text.rb +215 -0
  65. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +11 -20
  66. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +2 -3
  67. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +21 -14
  68. data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +2 -1
  69. data/lib/aws_advanced_ruby_driver_wrapper/utils/saml_utils.rb +133 -0
  70. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +9 -5
  71. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/null_telemetry.rb +89 -0
  72. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_context.rb +124 -0
  73. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_counter.rb +39 -0
  74. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_factory.rb +122 -0
  75. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/open_telemetry_gauge.rb +36 -0
  76. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_const.rb +50 -0
  77. data/lib/aws_advanced_ruby_driver_wrapper/utils/telemetry/telemetry_factory.rb +143 -0
  78. data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +9 -2
  79. data/lib/aws_advanced_ruby_driver_wrapper/version.rb +1 -1
  80. data/lib/aws_advanced_ruby_driver_wrapper.rb +9 -2
  81. metadata +54 -2
@@ -18,6 +18,7 @@ require 'concurrent'
18
18
  require_relative '../../logging'
19
19
  require_relative '../../property_definition'
20
20
  require_relative '../../utils/rds_utils'
21
+ require_relative 'drop_blue_connections_marker'
21
22
  require_relative 'iam_host_tracker'
22
23
  require_relative 'interval_rate'
23
24
  require_relative 'host_mapper'
@@ -61,6 +62,10 @@ module AwsAdvancedRubyDriverWrapper
61
62
  @process_status_lock = Mutex.new
62
63
  @monitor_generation = 0
63
64
 
65
+ @drop_blue_connections = PropertyDefinition::BG_DROP_BLUE_CONNECTIONS.get_bool(wrapper_props)
66
+ # Identity of the drop-blue-connections marker created for the current switchover, if any.
67
+ @drop_marker_id = nil
68
+
64
69
  @status_check_interval_map = {
65
70
  IntervalRate::BASELINE => PropertyDefinition::BG_INTERVAL_BASELINE_SEC.get_float(wrapper_props) * 1000,
66
71
  IntervalRate::INCREASED => PropertyDefinition::BG_INTERVAL_INCREASED_SEC.get_float(wrapper_props) * 1000,
@@ -176,6 +181,18 @@ module AwsAdvancedRubyDriverWrapper
176
181
 
177
182
  logger.warn { 'Blue/Green switchover has timed out.' }
178
183
  @summary_status = @rollback ? @builder.created : @builder.completed(@rollback)
184
+ # A timeout means the wrapper stopped waiting, not that the switchover is confirmed. Only
185
+ # activate the marker if the switchover was last observed at POST (past the point of no
186
+ # return); timing out in PREPARATION/IN_PROGRESS could still roll back, and dropping blue
187
+ # connections then would hit a blue that is still primary. In that case discard the pending
188
+ # marker so it does not linger unactivated for the full marker TTL: we have given up waiting
189
+ # on this switchover, and no later phase will arrive to activate it.
190
+ if @latest_status_phase == Phase::POST
191
+ activate_drop_marker
192
+ else
193
+ remove_drop_marker
194
+ @drop_marker_id = nil
195
+ end
179
196
  update_monitors
180
197
  update_status_cache
181
198
  log_current_context
@@ -191,7 +208,15 @@ module AwsAdvancedRubyDriverWrapper
191
208
  @monitors = { Role::SOURCE => nil, Role::TARGET => nil }
192
209
  old_monitors.each_value { |m| m&.stop }
193
210
 
194
- Plugins::IamAuthPlugin.clear_cache(@storage_service) if iam_enabled?
211
+ # A completed switchover's marker must survive this reset so connections opened to the old
212
+ # blue environment are still invalidated on their next use. A rollback leaves blue as primary,
213
+ # so its marker is discarded instead. Either way a later switchover starts a fresh marker.
214
+ remove_drop_marker if @rollback
215
+ @drop_marker_id = nil
216
+
217
+ # IamAuthPlugin, FederatedAuthPlugin, and OktaAuthPlugin all cache tokens under the same
218
+ # shared IAM_TOKEN_CACHE_NAME, so clearing through any one of them clears all of them.
219
+ Plugins::IamAuthPlugin.clear_cache(@storage_service) if iam_token_cache_in_use?
195
220
 
196
221
  @rollback = false
197
222
  @green_topology_recognized_logged = false
@@ -225,12 +250,15 @@ module AwsAdvancedRubyDriverWrapper
225
250
  @switchover_state.update_dns_flags(@bgd_id, role, interim_status)
226
251
  @summary_status = @builder.in_progress(@rollback)
227
252
  @switchover_state.trigger_in_progress_monitor_reset
253
+ ensure_drop_marker_pending
228
254
  when Phase::POST
229
255
  @switchover_state.update_dns_flags(@bgd_id, role, interim_status)
230
256
  @summary_status = @builder.post(@rollback)
257
+ activate_drop_marker
231
258
  when Phase::COMPLETED
232
259
  @switchover_state.update_dns_flags(@bgd_id, role, interim_status)
233
260
  @summary_status = @builder.completed(@rollback)
261
+ activate_drop_marker
234
262
  else
235
263
  raise ArgumentError, "[bgd_id: '#{@bgd_id}'] Unknown BG phase '#{@latest_status_phase}'."
236
264
  end
@@ -350,14 +378,73 @@ module AwsAdvancedRubyDriverWrapper
350
378
  )
351
379
  end
352
380
 
381
+ # Creates a pending drop-blue-connections marker the first time a switchover reaches IN_PROGRESS.
382
+ # The marker is only confirmed (see #activate_drop_marker) once the switchover passes the point
383
+ # of no return, so nothing is invalidated while it is still in progress and could roll back.
384
+ def ensure_drop_marker_pending
385
+ return unless @drop_blue_connections
386
+ return if @rollback
387
+ return if @drop_marker_id
388
+
389
+ hosts = blue_host_set
390
+ return if hosts.empty?
391
+
392
+ marker = DropBlueConnectionsMarker.new(blue_hosts: hosts, switchover_time_ns: nano_time)
393
+ @drop_marker_id = marker.id
394
+ store_drop_marker(marker)
395
+ end
396
+
397
+ # Confirms the switchover's pending marker so stale blue connections begin to be invalidated.
398
+ # A marker is only created when IN_PROGRESS is observed (see #ensure_drop_marker_pending), since
399
+ # that is the only point with a reliable switchover time. A provider that first sees the
400
+ # deployment at POST/COMPLETED cannot tell which of its connections predate the switchover and
401
+ # would wrongly cover connections opened after switchover, including to green.
402
+ def activate_drop_marker
403
+ return unless @drop_blue_connections
404
+ return if @rollback
405
+
406
+ marker = read_drop_marker
407
+ return if marker.nil? || marker.active?
408
+
409
+ store_drop_marker(marker.activated)
410
+ end
411
+
412
+ # The blue host set: downcased SOURCE endpoint names plus their pre-switchover IP addresses.
413
+ def blue_host_set
414
+ return Set.new unless @summary_status
415
+
416
+ blue = @summary_status.role_by_host.select { |_host, role| role == Role::SOURCE }.keys
417
+ ips = blue.filter_map { |host| @host_mapper.host_ip_addresses[host] }
418
+ (blue + ips).to_set(&:downcase)
419
+ end
420
+
421
+ def store_drop_marker(marker)
422
+ @storage_service.set(BlueGreenPlugin::DROP_MARKER_NAME, BlueGreenPlugin.drop_marker_key(@bgd_id), marker)
423
+ end
424
+
425
+ def read_drop_marker
426
+ @storage_service.get(BlueGreenPlugin::DROP_MARKER_NAME, BlueGreenPlugin.drop_marker_key(@bgd_id), register_access: false)
427
+ end
428
+
429
+ def remove_drop_marker
430
+ @storage_service.remove(BlueGreenPlugin::DROP_MARKER_NAME, BlueGreenPlugin.drop_marker_key(@bgd_id))
431
+ end
432
+
433
+ def nano_time
434
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
435
+ end
436
+
353
437
  def switchover_finalized?
354
438
  completed = (!@rollback && @summary_status.current_phase == Phase::COMPLETED) ||
355
439
  (@rollback && @summary_status.current_phase == Phase::CREATED)
356
440
  completed && @event_log.any? { |_, v| v.phase&.active_switchover_or_completed? }
357
441
  end
358
442
 
359
- def iam_enabled?
360
- @service_container.plugin_manager&.plugin_in_use?(Plugins::IamAuthPlugin) || false
443
+ # True when any auth plugin backed by the shared IAM token cache (IAM or AD FS
444
+ # or Okta) is in use, so a switchover knows to purge that cache regardless
445
+ # of which one is active.
446
+ def iam_token_cache_in_use?
447
+ @service_container.plugin_manager&.iam_token_plugin_in_use? || false
361
448
  end
362
449
 
363
450
  def context_hash
@@ -41,7 +41,14 @@ module AwsAdvancedRubyDriverWrapper
41
41
  refresh_rate_sec,
42
42
  refresh_rate_backoff_factor,
43
43
  max_refresh_rate_sec,
44
- rds_client_func: ->(_, region) { Aws::RDS::Client.new(region: region) }
44
+ credentials_provider: nil,
45
+ rds_client_func: lambda { |_, client_region|
46
+ # Pass the configured credentials provider when one is set; otherwise omit :credentials
47
+ # so the SDK resolves from its default chain.
48
+ options = { region: client_region }
49
+ options[:credentials] = credentials_provider if credentials_provider
50
+ Aws::RDS::Client.new(**options)
51
+ }
45
52
  )
46
53
  super(termination_timeout_sec: TERMINATION_TIMEOUT_SEC)
47
54
 
@@ -58,6 +65,7 @@ module AwsAdvancedRubyDriverWrapper
58
65
  @refresh_cv = ConditionVariable.new
59
66
  @refresh_required = false
60
67
  @connection_issue = false
68
+ @info_changed_counter = service_container.telemetry_factory.create_counter('custom_endpoint.info_changed.count')
61
69
  end
62
70
 
63
71
  def endpoint_info?
@@ -161,6 +169,7 @@ module AwsAdvancedRubyDriverWrapper
161
169
  logger.debug("[#{@endpoint_id}] Custom endpoint info changed: #{endpoint_info}")
162
170
  cache_allowed_blocked(endpoint_info)
163
171
  cache_info(endpoint_info)
172
+ @info_changed_counter.inc
164
173
  speedup_refresh_rate
165
174
 
166
175
  elapsed = monotonic_time - start
@@ -241,6 +250,7 @@ module AwsAdvancedRubyDriverWrapper
241
250
  end
242
251
 
243
252
  def cached_info
253
+ # Explicitly unregistered: a monitor reading its own data must not keep itself alive.
244
254
  storage_service.get(ENDPOINT_INFO_CACHE_NAME, @custom_endpoint_host.url, register_access: false)
245
255
  end
246
256
 
@@ -38,6 +38,7 @@ module AwsAdvancedRubyDriverWrapper
38
38
  @props = props
39
39
  @should_wait_for_info = PropertyDefinition::WAIT_FOR_CUSTOM_ENDPOINT_INFO.get_bool(props)
40
40
  @wait_timeout_sec = PropertyDefinition::WAIT_FOR_CUSTOM_ENDPOINT_INFO_TIMEOUT_SEC.get_float(props)
41
+ @wait_for_info_counter = service_container.telemetry_factory.create_counter('custom_endpoint.wait_for_info.count')
41
42
 
42
43
  @custom_endpoint_host = nil
43
44
  @endpoint_id = nil
@@ -128,7 +129,8 @@ module AwsAdvancedRubyDriverWrapper
128
129
  @region,
129
130
  PropertyDefinition::CUSTOM_ENDPOINT_INFO_REFRESH_RATE_SEC.get_float(props),
130
131
  PropertyDefinition::CUSTOM_ENDPOINT_INFO_REFRESH_RATE_BACKOFF_FACTOR.get_int(props),
131
- PropertyDefinition::CUSTOM_ENDPOINT_INFO_MAX_REFRESH_RATE_SEC.get_float(props)
132
+ PropertyDefinition::CUSTOM_ENDPOINT_INFO_MAX_REFRESH_RATE_SEC.get_float(props),
133
+ credentials_provider: PropertyDefinition::AWS_CREDENTIALS_PROVIDER.get(props)
132
134
  )
133
135
  end
134
136
  end
@@ -137,6 +139,7 @@ module AwsAdvancedRubyDriverWrapper
137
139
  return if monitor.endpoint_info?
138
140
 
139
141
  monitor.request_endpoint_info_update
142
+ @wait_for_info_counter.inc
140
143
  logger.debug("CustomEndpointPlugin: waiting up to #{@wait_timeout_sec}s for endpoint info on #{@custom_endpoint_host.url}")
141
144
 
142
145
  return if monitor.wait_for_info?(@wait_timeout_sec)
@@ -18,6 +18,7 @@ require 'concurrent'
18
18
  require_relative '../errors'
19
19
  require_relative '../host/host_info'
20
20
  require_relative '../host/host_availability'
21
+ require_relative '../utils/telemetry/telemetry_const'
21
22
 
22
23
  module AwsAdvancedRubyDriverWrapper
23
24
  module Plugins
@@ -44,7 +45,7 @@ module AwsAdvancedRubyDriverWrapper
44
45
  host_info
45
46
  end
46
47
 
47
- conn = driver_dialect.connect(target_host_info, driver_props)
48
+ conn = with_driver_span { driver_dialect.connect(target_host_info, driver_props) }
48
49
 
49
50
  if conn.nil?
50
51
  raise Errors::AwsError,
@@ -83,7 +84,7 @@ module AwsAdvancedRubyDriverWrapper
83
84
 
84
85
  def internal_connect(host_info, driver_props, _wrapper_props, _is_initial_connection, _pipeline_callable)
85
86
  driver_dialect = @service_container.dialect_service.driver_dialect
86
- driver_dialect.connect(host_info, driver_props)
87
+ with_driver_span { driver_dialect.connect(host_info, driver_props) }
87
88
  end
88
89
 
89
90
  def execute(target_method_name, target_callable, *args, **, &)
@@ -92,7 +93,7 @@ module AwsAdvancedRubyDriverWrapper
92
93
  succeeded = false
93
94
 
94
95
  begin
95
- result = target_callable.call(*args, **, &)
96
+ result = with_driver_span { target_callable.call(*args, **, &) }
96
97
  succeeded = true
97
98
  result
98
99
  ensure
@@ -103,6 +104,16 @@ module AwsAdvancedRubyDriverWrapper
103
104
  end
104
105
  end
105
106
  end
107
+
108
+ private
109
+
110
+ # Records the time spent in the driver itself as a span named after the driver.
111
+ def with_driver_span(&)
112
+ telemetry_factory = @service_container.telemetry_factory
113
+ return yield unless telemetry_factory.traces_in_use?
114
+
115
+ telemetry_factory.in_context(@service_container.dialect_service.driver_dialect.name, Utils::Telemetry::TraceLevel::NESTED, &)
116
+ end
106
117
  end
107
118
  end
108
119
  end
@@ -20,9 +20,11 @@ require_relative '../host/host_info'
20
20
  require_relative '../host/host_role'
21
21
  require_relative '../host/host_availability'
22
22
  require_relative '../logging'
23
+ require_relative '../services/host_service'
23
24
  require_relative '../utils/rds_utils'
24
25
  require_relative '../utils/rds_url_type'
25
26
  require_relative '../utils/retry_util'
27
+ require_relative '../utils/telemetry/telemetry_const'
26
28
  require_relative '../wrapper_property'
27
29
  require_relative 'failover_mode'
28
30
 
@@ -32,14 +34,25 @@ module AwsAdvancedRubyDriverWrapper
32
34
  include Logging
33
35
 
34
36
  ReaderFailoverResult = Data.define(:connection, :host_info)
37
+ FailoverCounters = Data.define(:triggered, :succeeded, :failed)
38
+
39
+ SUCCESSFUL_FAILOVER_ERRORS = [Errors::FailoverSuccessError, Errors::TransactionStateUnknownError].freeze
40
+
41
+ TELEMETRY_WRITER_FAILOVER = 'failover to writer host'
42
+ TELEMETRY_READER_FAILOVER = 'failover to replica'
35
43
 
36
44
  def initialize(service_container, props = ::Concurrent::Map.new)
37
45
  @service_container = service_container
38
46
  @wrapper_props = props
47
+ @telemetry_factory = service_container.telemetry_factory
48
+ @telemetry_failover_additional_top_trace = PropertyDefinition::TELEMETRY_FAILOVER_ADDITIONAL_TOP_TRACE.get_bool(props)
49
+ @writer_failover_counters = failover_counters('writer_failover')
50
+ @reader_failover_counters = failover_counters('reader_failover')
39
51
 
40
52
  @retry_util = Utils::RetryUtil.new(service_container)
41
53
  @failover_timeout = PropertyDefinition::FAILOVER_TIMEOUT_SEC.get_float(props)
42
54
  @reader_selector_strategy = PropertyDefinition::FAILOVER_READER_HOST_SELECTOR_STRATEGY.get(props)
55
+ Services::HostService.validate_strategy!(@reader_selector_strategy)
43
56
  @failover_mode = nil
44
57
  @rds_url_type = nil
45
58
  @closed_explicitly = false
@@ -235,9 +248,60 @@ module AwsAdvancedRubyDriverWrapper
235
248
  end
236
249
 
237
250
  if @failover_mode == FailoverMode::STRICT_WRITER
238
- failover_writer
251
+ with_failover_telemetry(TELEMETRY_WRITER_FAILOVER) { count_failover(@writer_failover_counters) { failover_writer } }
239
252
  else
240
- failover_reader
253
+ with_failover_telemetry(TELEMETRY_READER_FAILOVER) { count_failover(@reader_failover_counters) { failover_reader } }
254
+ end
255
+ end
256
+
257
+ def failover_counters(prefix)
258
+ FailoverCounters.new(
259
+ triggered: @telemetry_factory.create_counter("#{prefix}.triggered.count"),
260
+ succeeded: @telemetry_factory.create_counter("#{prefix}.completed.success.count"),
261
+ failed: @telemetry_factory.create_counter("#{prefix}.completed.failed.count")
262
+ )
263
+ end
264
+
265
+ # Records a failover as a span. Failover always ends in an error: a successful one ends in one
266
+ # of SUCCESSFUL_FAILOVER_ERRORS, which marks the span as succeeded, and any other error marks it
267
+ # as failed. With telemetry_failover_additional_top_trace enabled, the span is also submitted as
268
+ # a trace of its own.
269
+ def with_failover_telemetry(span_name)
270
+ telemetry_context = nil
271
+ @telemetry_factory.in_context(span_name, Utils::Telemetry::TraceLevel::NESTED) do |context|
272
+ telemetry_context = context
273
+ yield
274
+ rescue *SUCCESSFUL_FAILOVER_ERRORS => e
275
+ context.success = true
276
+ context.record_exception(e)
277
+ raise
278
+ rescue StandardError => e
279
+ context.success = false
280
+ context.record_exception(e)
281
+ raise
282
+ end
283
+ ensure
284
+ submit_failover_copy(telemetry_context) if telemetry_context && @telemetry_failover_additional_top_trace
285
+ end
286
+
287
+ # Runs while the failover's outcome is propagating, so an error here must not replace it.
288
+ def submit_failover_copy(telemetry_context)
289
+ @telemetry_factory.post_copy(telemetry_context, Utils::Telemetry::TraceLevel::FORCE_TOP_LEVEL)
290
+ rescue StandardError => e
291
+ logger.debug { "Unable to submit the failover trace as a trace of its own: #{e.message}" }
292
+ end
293
+
294
+ # Counts a failover as triggered, and then as succeeded or failed according to how it ends.
295
+ def count_failover(counters)
296
+ counters.triggered.inc
297
+ begin
298
+ yield
299
+ rescue *SUCCESSFUL_FAILOVER_ERRORS
300
+ counters.succeeded.inc
301
+ raise
302
+ rescue StandardError
303
+ counters.failed.inc
304
+ raise
241
305
  end
242
306
  end
243
307
 
@@ -414,7 +478,8 @@ module AwsAdvancedRubyDriverWrapper
414
478
  Host::HostRole::READER,
415
479
  @reader_selector_strategy
416
480
  )
417
- rescue StandardError
481
+ rescue StandardError => e
482
+ logger.warn("Failed to select a reader using the '#{@reader_selector_strategy}' host selection strategy: #{e.message}")
418
483
  nil
419
484
  end
420
485
 
@@ -0,0 +1,266 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License").
6
+ # You may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+
17
+ require 'concurrent'
18
+ require_relative '../../errors'
19
+ require_relative '../../property_definition'
20
+ require_relative '../iam_auth_plugin'
21
+ require_relative '../../utils/iam_auth_utils'
22
+ require_relative '../../utils/rds_utils'
23
+ require_relative '../../utils/rds_url_type'
24
+ require_relative '../../utils/saml_utils'
25
+ require_relative '../../utils/telemetry/telemetry_const'
26
+
27
+ module AwsAdvancedRubyDriverWrapper
28
+ module Plugins
29
+ module FederatedAuth
30
+ # Shared connect flow for the SAML-based authentication plugins (AD FS and
31
+ # Okta).
32
+ #
33
+ # A subclass defines +self.fetch_saml_assertion+, +TELEMETRY_PREFIX+ (the prefix of its metric
34
+ # names) and +TELEMETRY_FETCH_SAML+ (the name of the span its SAML assertion fetch is recorded as).
35
+ class BaseSamlAuthPlugin
36
+ SUBSCRIBED_METHODS = Set['connect', 'internal_connect'].freeze
37
+ # Reuse the IAM plugin's cache name.
38
+ IAM_TOKEN_CACHE_NAME = AwsAdvancedRubyDriverWrapper::Plugins::IamAuthPlugin::IAM_TOKEN_CACHE_NAME
39
+
40
+ attr_reader :subscribed_methods
41
+
42
+ def initialize(service_container, props = ::Concurrent::Map.new)
43
+ ensure_aws_sdk!
44
+ @service_container = service_container
45
+ @wrapper_props = props
46
+ expiration = PropertyDefinition::IAM_EXPIRATION_SEC.get_float(props)
47
+ PropertyDefinition::IAM_EXPIRATION_SEC.validate!(expiration)
48
+ service_container.storage_service.register(IAM_TOKEN_CACHE_NAME, ttl: expiration)
49
+ @subscribed_methods = SUBSCRIBED_METHODS
50
+
51
+ # Each subclass names its metrics after itself, and its SAML assertion span after its IdP.
52
+ @telemetry_factory = service_container.telemetry_factory
53
+ @fetch_token_counter = @telemetry_factory.create_counter("#{self.class::TELEMETRY_PREFIX}.fetch_token.count")
54
+ @telemetry_factory.create_gauge(
55
+ "#{self.class::TELEMETRY_PREFIX}.token_cache.size",
56
+ &IamAuthPlugin.token_cache_size(service_container.storage_service)
57
+ )
58
+ end
59
+
60
+ def connect(host_info, driver_props, _is_initial_connection, pipeline_callable)
61
+ saml_connect(host_info, driver_props, pipeline_callable)
62
+ end
63
+
64
+ def internal_connect(host_info, driver_props, wrapper_props_override, _is_initial_connection, pipeline_callable)
65
+ saml_connect(host_info, driver_props, pipeline_callable, wrapper_props_override || @wrapper_props)
66
+ end
67
+
68
+ def self.clear_cache(storage_service)
69
+ storage_service.clear(IAM_TOKEN_CACHE_NAME)
70
+ end
71
+
72
+ # Exchanges a SAML assertion for temporary AWS credentials via STS
73
+ # AssumeRoleWithSAML.
74
+ #
75
+ # @return [Aws::Credentials]
76
+ def self.credentials_from_saml(saml_assertion, role_arn:, idp_arn:, region:)
77
+ require 'aws-sdk-core'
78
+ # AssumeRoleWithSAML is unsigned, so no AWS credentials are needed. Passing
79
+ # credentials: nil keeps the SDK from running the default credential chain (which
80
+ # on a credential-less, non-EC2 host wastes ~1s probing IMDS and logs a misleading
81
+ # "Error retrieving instance profile credentials").
82
+ client_opts = { credentials: nil }
83
+ client_opts[:region] = region if region && !region.to_s.empty?
84
+ resp = Aws::STS::Client.new(**client_opts).assume_role_with_saml(
85
+ role_arn:, principal_arn: idp_arn, saml_assertion:
86
+ )
87
+ creds = resp.credentials
88
+ Aws::Credentials.new(creds.access_key_id, creds.secret_access_key, creds.session_token)
89
+ end
90
+
91
+ private
92
+
93
+ def saml_connect(host_info, driver_props, pipeline_callable, wrapper_props_override = nil)
94
+ wrapper_props_override ||= @wrapper_props
95
+ saml_props = wrapper_props_override
96
+
97
+ validate_required!(saml_props)
98
+
99
+ # The connection's driver_props :user/:password double as the fallback IdP login when
100
+ # idp_username / idp_password are not set. We overwrite driver_props with db_user and the
101
+ # RDS token further down, and that hash is reused across reconnects (failover, retry,
102
+ # Blue/Green rerouting), so capture the fallback into the wrapper props once, up front.
103
+ # A later reconnect then still logs in to the IdP with the original credentials rather
104
+ # than db_user and a stale token.
105
+ persist_idp_credentials_fallback(saml_props, driver_props)
106
+ db_user = PropertyDefinition::DB_USER.get(saml_props)
107
+ idp_username = PropertyDefinition::IDP_USERNAME.get(saml_props)
108
+ idp_password = PropertyDefinition::IDP_PASSWORD.get(saml_props)
109
+
110
+ host = Utils::IamAuthUtils.resolve_host(
111
+ PropertyDefinition::IAM_HOST.get(wrapper_props_override), host_info
112
+ )
113
+ port = Utils::IamAuthUtils.resolve_port(
114
+ PropertyDefinition::IAM_PORT.get(wrapper_props_override),
115
+ host_info,
116
+ @service_container.dialect_service.db_dialect.default_port
117
+ )
118
+ rds_type = Utils::RdsUtils.identify_rds_type(host)
119
+
120
+ # Only a global writer cluster with no explicit iam_region needs AWS credentials to look
121
+ # its region up. In that one case the SAML exchange has to happen before the region lookup
122
+ # (and so before the token-cache check); every other case - including a global cluster
123
+ # whose iam_region is set - defers the IdP round-trip to the token-cache miss path, so a
124
+ # pool or failover storm does not trigger one IdP login per connection.
125
+ explicit_region = PropertyDefinition::IAM_REGION.get(wrapper_props_override)
126
+ preassumed_credentials = nil
127
+ if rds_type == Utils::RdsUrlType::RDS_GLOBAL_WRITER_CLUSTER && (explicit_region.nil? || explicit_region.to_s.empty?)
128
+ preassumed_credentials = obtain_credentials(saml_props, nil, idp_username, idp_password)
129
+ end
130
+
131
+ region = Utils::IamAuthUtils.region_for(
132
+ host:, props: wrapper_props_override, rds_type:,
133
+ credentials_provider: preassumed_credentials,
134
+ rds_client_func: preassumed_credentials ? -> { Aws::RDS::Client.new(credentials: preassumed_credentials) } : nil
135
+ )
136
+ unless region
137
+ raise Errors::SamlAuthError,
138
+ "#{self.class.name}: unable to determine connection region. " \
139
+ "If you are using a non-standard RDS URL, please set the 'iam_region' property."
140
+ end
141
+
142
+ fingerprint = Utils::SamlUtils.principal_fingerprint(
143
+ PropertyDefinition::IAM_ROLE_ARN.get(saml_props),
144
+ PropertyDefinition::IAM_IDP_ARN.get(saml_props)
145
+ )
146
+ cache_key = Utils::IamAuthUtils.cache_key(region, host, port, db_user, fingerprint)
147
+ entry = @service_container.storage_service.get(IAM_TOKEN_CACHE_NAME, cache_key)
148
+ expiration = PropertyDefinition::IAM_EXPIRATION_SEC.get_float(wrapper_props_override)
149
+ PropertyDefinition::IAM_EXPIRATION_SEC.validate!(expiration)
150
+
151
+ if Utils::IamAuthUtils.valid_entry?(entry)
152
+ token = entry.token
153
+ is_cached_token = true
154
+ else
155
+ token = fetch_and_cache_token(saml_props, region, host, port, db_user, cache_key, expiration,
156
+ idp_username, idp_password, preassumed_credentials)
157
+ is_cached_token = false
158
+ end
159
+
160
+ user_key = @service_container.dialect_service.driver_dialect.user_property_key
161
+ driver_props[user_key] = db_user
162
+ driver_props[:password] = token
163
+
164
+ begin
165
+ pipeline_callable.call
166
+ rescue StandardError => e
167
+ raise unless is_cached_token && @service_container.dialect_service.login_error?(e)
168
+
169
+ driver_props[:password] = fetch_and_cache_token(saml_props, region, host, port, db_user, cache_key,
170
+ expiration, idp_username, idp_password, nil)
171
+ pipeline_callable.call
172
+ end
173
+ end
174
+
175
+ # Mints a fresh RDS token signed with SAML-derived credentials and caches it.
176
+ def fetch_and_cache_token(saml_props, region, host, port, db_user, cache_key, expiration,
177
+ idp_username, idp_password, credentials)
178
+ credentials ||= obtain_credentials(saml_props, region, idp_username, idp_password)
179
+ @fetch_token_counter.inc
180
+ token = Utils::IamAuthUtils.generate_token(
181
+ region:, hostname: host, port:, user: db_user, credentials_provider: credentials,
182
+ telemetry_factory: @telemetry_factory
183
+ )
184
+ @service_container.storage_service.set(
185
+ IAM_TOKEN_CACHE_NAME, cache_key, Utils::IamAuthUtils.build_token_entry(token, expiration)
186
+ )
187
+ token
188
+ end
189
+
190
+ # Captures the connection's user/password as the IdP login when idp_username /
191
+ # idp_password are unset, persisting them into the wrapper props so the value survives
192
+ # the driver_props overwrite later in the flow and any subsequent reconnect. Never stores
193
+ # a nil (Concurrent::Map rejects nil values), so a connection with no fallback simply
194
+ # keeps idp_username / idp_password unset.
195
+ def persist_idp_credentials_fallback(saml_props, driver_props)
196
+ if PropertyDefinition::IDP_USERNAME.get(saml_props).nil?
197
+ fallback_user = driver_props[:user] || driver_props[:username]
198
+ PropertyDefinition::IDP_USERNAME.set(saml_props, fallback_user) unless fallback_user.nil?
199
+ end
200
+ return unless PropertyDefinition::IDP_PASSWORD.get(saml_props).nil?
201
+
202
+ fallback_password = driver_props[:password]
203
+ PropertyDefinition::IDP_PASSWORD.set(saml_props, fallback_password) unless fallback_password.nil?
204
+ end
205
+
206
+ # Fetches a SAML assertion from the IdP and exchanges it for AWS credentials.
207
+ def obtain_credentials(saml_props, region, idp_username, idp_password)
208
+ assertion = @telemetry_factory.in_context(self.class::TELEMETRY_FETCH_SAML, Utils::Telemetry::TraceLevel::NESTED,
209
+ record_failure: true) do
210
+ self.class.fetch_saml_assertion(**saml_fetch_options(saml_props, idp_username, idp_password))
211
+ end
212
+ self.class.credentials_from_saml(
213
+ assertion,
214
+ role_arn: PropertyDefinition::IAM_ROLE_ARN.get(saml_props),
215
+ idp_arn: PropertyDefinition::IAM_IDP_ARN.get(saml_props),
216
+ region:
217
+ )
218
+ rescue Errors::SamlAuthError
219
+ raise
220
+ rescue StandardError => e
221
+ # Surface IdP/STS failures (DNS, TLS, STS errors) as a SamlAuthError.
222
+ raise Errors::SamlAuthError, "#{self.class.name}: failed to obtain credentials via SAML: #{e.message}"
223
+ end
224
+
225
+ def validate_required!(saml_props)
226
+ missing = required_identity(saml_props).select { |_name, value| value.nil? || value.to_s.empty? }
227
+ return if missing.empty?
228
+
229
+ names = missing.map(&:first)
230
+ raise Errors::SamlAuthError,
231
+ "#{self.class.name}: #{names.join(', ')} #{names.length > 1 ? 'are' : 'is'} required"
232
+ end
233
+
234
+ # The properties that must be present for this IdP. Subclasses override to
235
+ # append their own (e.g. Okta's app_id).
236
+ def required_identity(saml_props)
237
+ [
238
+ ['db_user', PropertyDefinition::DB_USER.get(saml_props)],
239
+ ['idp_endpoint', PropertyDefinition::IDP_ENDPOINT.get(saml_props)],
240
+ ['iam_role_arn', PropertyDefinition::IAM_ROLE_ARN.get(saml_props)],
241
+ ['iam_idp_arn', PropertyDefinition::IAM_IDP_ARN.get(saml_props)]
242
+ ]
243
+ end
244
+
245
+ # The keyword arguments passed to .fetch_saml_assertion. Subclasses override.
246
+ def saml_fetch_options(saml_props, idp_username, idp_password)
247
+ {
248
+ idp_endpoint: PropertyDefinition::IDP_ENDPOINT.get(saml_props),
249
+ idp_username:,
250
+ idp_password:,
251
+ ssl_insecure: PropertyDefinition::SSL_INSECURE.get_bool(saml_props),
252
+ socket_timeout_sec: PropertyDefinition::HTTP_CLIENT_SOCKET_TIMEOUT_SEC.get_float(saml_props),
253
+ connect_timeout_sec: PropertyDefinition::HTTP_CLIENT_CONNECT_TIMEOUT_SEC.get_float(saml_props)
254
+ }
255
+ end
256
+
257
+ def ensure_aws_sdk!
258
+ require 'aws-sdk-rds'
259
+ rescue LoadError
260
+ raise LoadError,
261
+ "The SAML auth plugins require 'aws-sdk-rds'. Add it to your Gemfile: gem 'aws-sdk-rds'"
262
+ end
263
+ end
264
+ end
265
+ end
266
+ end