aws_advanced_ruby_driver_wrapper 1.0.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 (150) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +23 -0
  3. data/LICENSE +175 -0
  4. data/NOTICE +1 -0
  5. data/README.md +168 -0
  6. data/THIRD-PARTY-LICENSES +473 -0
  7. data/aws_advanced_ruby_driver_wrapper.gemspec +73 -0
  8. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_mysql2_adapter.rb +73 -0
  9. data/lib/aws_advanced_ruby_driver_wrapper/active_record/aws_postgresql_adapter.rb +95 -0
  10. data/lib/aws_advanced_ruby_driver_wrapper/custom_configuration.rb +58 -0
  11. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_mysql_dialect.rb +103 -0
  12. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/aurora_pg_dialect.rb +124 -0
  13. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/dialect_codes.rb +38 -0
  14. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_mysql_dialect.rb +91 -0
  15. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/global_pg_dialect.rb +92 -0
  16. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_mysql_dialect.rb +95 -0
  17. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/multi_az_cluster_pg_dialect.rb +86 -0
  18. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/mysql_dialect.rb +98 -0
  19. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/pg_dialect.rb +95 -0
  20. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_mysql_dialect.rb +88 -0
  21. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/rds_pg_dialect.rb +86 -0
  22. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/unknown_dialect.rb +72 -0
  23. data/lib/aws_advanced_ruby_driver_wrapper/db_dialects/utils/dialect_utils.rb +71 -0
  24. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect.rb +154 -0
  25. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/driver_dialect_manager.rb +55 -0
  26. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/mysql_driver_dialect.rb +165 -0
  27. data/lib/aws_advanced_ruby_driver_wrapper/driver_dialects/pg_driver_dialect.rb +201 -0
  28. data/lib/aws_advanced_ruby_driver_wrapper/errors/error_handler.rb +62 -0
  29. data/lib/aws_advanced_ruby_driver_wrapper/errors/mysql_error_handler.rb +80 -0
  30. data/lib/aws_advanced_ruby_driver_wrapper/errors/pg_error_handler.rb +126 -0
  31. data/lib/aws_advanced_ruby_driver_wrapper/errors.rb +59 -0
  32. data/lib/aws_advanced_ruby_driver_wrapper/host/connection_string_host_list_provider.rb +95 -0
  33. data/lib/aws_advanced_ruby_driver_wrapper/host/global_aurora_host_list_provider.rb +65 -0
  34. data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability.rb +24 -0
  35. data/lib/aws_advanced_ruby_driver_wrapper/host/host_availability_strategy.rb +27 -0
  36. data/lib/aws_advanced_ruby_driver_wrapper/host/host_info.rb +137 -0
  37. data/lib/aws_advanced_ruby_driver_wrapper/host/host_role.rb +25 -0
  38. data/lib/aws_advanced_ruby_driver_wrapper/host/random_host_selector.rb +40 -0
  39. data/lib/aws_advanced_ruby_driver_wrapper/host/rds_host_list_provider.rb +206 -0
  40. data/lib/aws_advanced_ruby_driver_wrapper/logging.rb +110 -0
  41. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/cluster_topology_monitor.rb +709 -0
  42. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/global_cluster_topology_monitor.rb +72 -0
  43. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor.rb +99 -0
  44. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_connection.rb +57 -0
  45. data/lib/aws_advanced_ruby_driver_wrapper/monitoring/monitor_state.rb +25 -0
  46. data/lib/aws_advanced_ruby_driver_wrapper/mysql.rb +429 -0
  47. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/blue_green_plugin.rb +205 -0
  48. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/host_mapper.rb +132 -0
  49. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/iam_host_tracker.rb +84 -0
  50. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interim_status.rb +92 -0
  51. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/interval_rate.rb +27 -0
  52. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase.rb +69 -0
  53. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_event_log.rb +85 -0
  54. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/phase_time_info.rb +25 -0
  55. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/role.rb +38 -0
  56. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/base_routing.rb +83 -0
  57. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/reject_connect_routing.rb +40 -0
  58. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/substitute_connect_routing.rb +136 -0
  59. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_connect_routing.rb +53 -0
  60. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_execute_routing.rb +52 -0
  61. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/routing/suspend_until_corresponding_host_found_connect_routing.rb +83 -0
  62. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status.rb +68 -0
  63. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_builder.rb +244 -0
  64. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_info.rb +30 -0
  65. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_monitor.rb +564 -0
  66. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/status_provider.rb +414 -0
  67. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_state.rb +98 -0
  68. data/lib/aws_advanced_ruby_driver_wrapper/plugins/blue_green/switchover_timer.rb +46 -0
  69. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_monitor.rb +266 -0
  70. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/custom_endpoint_plugin.rb +158 -0
  71. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/info.rb +111 -0
  72. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/member_list_type.rb +31 -0
  73. data/lib/aws_advanced_ruby_driver_wrapper/plugins/custom_endpoint/role.rb +45 -0
  74. data/lib/aws_advanced_ruby_driver_wrapper/plugins/default_plugin.rb +108 -0
  75. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_mode.rb +43 -0
  76. data/lib/aws_advanced_ruby_driver_wrapper/plugins/failover_plugin.rb +467 -0
  77. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_mode.rb +68 -0
  78. data/lib/aws_advanced_ruby_driver_wrapper/plugins/gdb/gdb_failover_plugin.rb +403 -0
  79. data/lib/aws_advanced_ruby_driver_wrapper/plugins/iam_auth_plugin.rb +159 -0
  80. data/lib/aws_advanced_ruby_driver_wrapper/plugins/initial_connection_strategy_plugin.rb +485 -0
  81. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/audit_logger.rb +157 -0
  82. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_cipher.rb +159 -0
  83. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/column_encryption_config.rb +61 -0
  84. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/connection_source.rb +91 -0
  85. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/data_key_cache.rb +220 -0
  86. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_algorithm.rb +75 -0
  87. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_config.rb +146 -0
  88. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/encryption_service.rb +391 -0
  89. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/error_context.rb +198 -0
  90. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/errors.rb +259 -0
  91. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_management_utility.rb +435 -0
  92. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_manager.rb +378 -0
  93. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/key_metadata.rb +86 -0
  94. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_plugin.rb +890 -0
  95. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/kms_encryption_utility.rb +281 -0
  96. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/metadata_manager.rb +332 -0
  97. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sanitizer.rb +147 -0
  98. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_name.rb +70 -0
  99. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/schema_validator.rb +211 -0
  100. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/sql_runner.rb +147 -0
  101. data/lib/aws_advanced_ruby_driver_wrapper/plugins/kms_encryption/type_marker.rb +109 -0
  102. data/lib/aws_advanced_ruby_driver_wrapper/plugins/secrets_manager_plugin.rb +358 -0
  103. data/lib/aws_advanced_ruby_driver_wrapper/postgresql.rb +659 -0
  104. data/lib/aws_advanced_ruby_driver_wrapper/property_definition.rb +409 -0
  105. data/lib/aws_advanced_ruby_driver_wrapper/ruby_method.rb +122 -0
  106. data/lib/aws_advanced_ruby_driver_wrapper/services/connection_service.rb +143 -0
  107. data/lib/aws_advanced_ruby_driver_wrapper/services/dialect_service.rb +267 -0
  108. data/lib/aws_advanced_ruby_driver_wrapper/services/host_service.rb +199 -0
  109. data/lib/aws_advanced_ruby_driver_wrapper/services/monitor_service.rb +186 -0
  110. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_call_context.rb +63 -0
  111. data/lib/aws_advanced_ruby_driver_wrapper/services/plugin_manager.rb +273 -0
  112. data/lib/aws_advanced_ruby_driver_wrapper/services/service_container.rb +30 -0
  113. data/lib/aws_advanced_ruby_driver_wrapper/services/service_utility.rb +78 -0
  114. data/lib/aws_advanced_ruby_driver_wrapper/services/session_state_service.rb +56 -0
  115. data/lib/aws_advanced_ruby_driver_wrapper/utils/accessible_regions.rb +52 -0
  116. data/lib/aws_advanced_ruby_driver_wrapper/utils/ar_constants.rb +25 -0
  117. data/lib/aws_advanced_ruby_driver_wrapper/utils/aurora_topology_utils.rb +99 -0
  118. data/lib/aws_advanced_ruby_driver_wrapper/utils/aws_credentials_utils.rb +62 -0
  119. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config.rb +91 -0
  120. data/lib/aws_advanced_ruby_driver_wrapper/utils/connection_config_parser.rb +368 -0
  121. data/lib/aws_advanced_ruby_driver_wrapper/utils/conversion_utils.rb +51 -0
  122. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/batching_event_publisher.rb +119 -0
  123. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/data_access_event.rb +26 -0
  124. data/lib/aws_advanced_ruby_driver_wrapper/utils/events/monitor_reset_event.rb +26 -0
  125. data/lib/aws_advanced_ruby_driver_wrapper/utils/global_aurora_topology_utils.rb +185 -0
  126. data/lib/aws_advanced_ruby_driver_wrapper/utils/host_list_utils.rb +27 -0
  127. data/lib/aws_advanced_ruby_driver_wrapper/utils/iam_auth_utils.rb +112 -0
  128. data/lib/aws_advanced_ruby_driver_wrapper/utils/multi_az_topology_utils.rb +117 -0
  129. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/encryption_annotation_parser.rb +99 -0
  130. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/mysql_statement_analyzer.rb +641 -0
  131. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/pg_statement_analyzer.rb +502 -0
  132. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_analysis.rb +63 -0
  133. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/query_type.rb +35 -0
  134. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint.rb +27 -0
  135. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/routing_hint_parser.rb +50 -0
  136. data/lib/aws_advanced_ruby_driver_wrapper/utils/parser/sql_parser.rb +139 -0
  137. data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_url_type.rb +71 -0
  138. data/lib/aws_advanced_ruby_driver_wrapper/utils/rds_utils.rb +575 -0
  139. data/lib/aws_advanced_ruby_driver_wrapper/utils/retry_util.rb +153 -0
  140. data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_encoding.rb +56 -0
  141. data/lib/aws_advanced_ruby_driver_wrapper/utils/sql_method_analyzer.rb +195 -0
  142. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/cache_entry.rb +56 -0
  143. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/expiration_cache.rb +108 -0
  144. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/sliding_expiration_cache.rb +137 -0
  145. data/lib/aws_advanced_ruby_driver_wrapper/utils/storage/storage_service.rb +172 -0
  146. data/lib/aws_advanced_ruby_driver_wrapper/utils/topology_utils.rb +127 -0
  147. data/lib/aws_advanced_ruby_driver_wrapper/version.rb +19 -0
  148. data/lib/aws_advanced_ruby_driver_wrapper/wrapper_property.rb +64 -0
  149. data/lib/aws_advanced_ruby_driver_wrapper.rb +116 -0
  150. metadata +227 -0
@@ -0,0 +1,358 @@
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 '../logging'
20
+ require_relative '../property_definition'
21
+ require_relative '../utils/aws_credentials_utils'
22
+
23
+ module AwsAdvancedRubyDriverWrapper
24
+ module Plugins
25
+ class SecretsManagerPlugin
26
+ include Logging
27
+
28
+ SUBSCRIBED_METHODS = Set['connect', 'internal_connect'].freeze
29
+ SECRETS_MANAGER_CACHE_NAME = :secrets_manager
30
+ MIN_EXPIRATION_SEC = 300
31
+ SYNC_FETCH_TIMEOUT_SEC = 60
32
+ # The partition segment of an ARN varies across AWS partitions, so match any of them.
33
+ SECRETS_ARN_PATTERN = %r{\Aarn:aws(?:-[a-z]+)*:secretsmanager:(?<region>[^:\n]+):[^:\n]*:(?:[^:/\n]*[:/])?}
34
+ MAX_RETRY_DELAY_SEC = 8
35
+
36
+ # The maximum time a fetched secret stays in the shared cache, measured from when it was
37
+ # fetched (the cache anchors expiry at store time and does not renew on read). Until its
38
+ # logical expiration (@expiration_sec, default 14.5 min) the entry is served fresh;
39
+ # between logical expiration and this disposal time it is served stale while a background
40
+ # refresh runs (SWR); after this it is physically removed and the next use re-fetches from
41
+ # Secrets Manager. This is the hard cap on how long a cached secret can live.
42
+ SECRET_CACHE_DISPOSAL_SEC = 20 * 60
43
+
44
+ # The window reserved between logical expiration and physical disposal for a background
45
+ # refresh to complete. The logical expiration is clamped so that at least this much time
46
+ # remains for stale-while-revalidate. Sized to the synchronous fetch timeout so a refresh
47
+ # has a full fetch's worth of time to land.
48
+ SWR_REVALIDATION_BUDGET_SEC = SYNC_FETCH_TIMEOUT_SEC
49
+
50
+ # The largest logical expiration we allow, leaving room for the SWR refresh window.
51
+ MAX_EXPIRATION_SEC = SECRET_CACHE_DISPOSAL_SEC - SWR_REVALIDATION_BUDGET_SEC
52
+
53
+ SecretEntry = Data.define(:username, :password, :expires_at) do
54
+ def expired?(now = Process.clock_gettime(Process::CLOCK_MONOTONIC))
55
+ expires_at && now >= expires_at
56
+ end
57
+
58
+ # Redact the password so the secret is never exposed if an instance is
59
+ # logged, interpolated, or rendered in a backtrace.
60
+ def inspect
61
+ "#<data SecretEntry username=#{username.inspect}, " \
62
+ "password=#{AwsAdvancedRubyDriverWrapper::REDACTED.inspect}, expires_at=#{expires_at.inspect}>"
63
+ end
64
+ alias_method :to_s, :inspect
65
+
66
+ # `pp` / PrettyPrint does not call #inspect; route them through the
67
+ # redacted representation so `pp entry` cannot leak the password.
68
+ def pretty_print(pp)
69
+ pp.text(inspect)
70
+ end
71
+ end
72
+
73
+ @pending_refreshes = Concurrent::Map.new
74
+
75
+ class << self
76
+ attr_reader :pending_refreshes
77
+
78
+ def clear_cache(storage_service)
79
+ storage_service.clear(SECRETS_MANAGER_CACHE_NAME)
80
+ end
81
+
82
+ # The secret's value depends on which secret is read, from which region and endpoint, and
83
+ # with which AWS credentials, so all of them are part of the key. Connections that differ in
84
+ # any of them neither share a cached secret nor wait on each other's fetch.
85
+ #
86
+ # @param endpoint [String, nil] the custom Secrets Manager endpoint, if any
87
+ # @param credentials_identity [String] from {Utils::AwsCredentialsUtils.identity}
88
+ def cache_key(secret_id, region, endpoint, credentials_identity)
89
+ "#{secret_id}:#{region}:#{endpoint}:#{credentials_identity}"
90
+ end
91
+ end
92
+
93
+ attr_reader :subscribed_methods
94
+
95
+ def initialize(service_container, props = ::Concurrent::Map.new)
96
+ ensure_sdk!
97
+ @service_container = service_container
98
+ @wrapper_props = props
99
+ @credentials_provider = PropertyDefinition::AWS_CREDENTIALS_PROVIDER.get(props) ||
100
+ Aws::CredentialProviderChain.new.resolve
101
+ @secret_id = PropertyDefinition::SECRET_ID.get(props)
102
+ raise Errors::SecretsManagerAuthError, 'secret_id is required' unless @secret_id
103
+
104
+ @region = resolve_region(props)
105
+ unless @region
106
+ raise Errors::SecretsManagerAuthError,
107
+ 'Unable to determine region; set :secret_region or use a Secrets Manager ARN'
108
+ end
109
+
110
+ @username_key = PropertyDefinition::SECRET_USERNAME_KEY.get_string(props)
111
+ @password_key = PropertyDefinition::SECRET_PASSWORD_KEY.get_string(props)
112
+ @expiration_sec = resolve_expiration(props)
113
+ @endpoint = PropertyDefinition::SECRET_ENDPOINT.get(props)
114
+ @rotation_retry_timeout_sec = PropertyDefinition::SECRET_ROTATION_RETRY_TIMEOUT_SEC.get_float(props)
115
+ @rotation_retry_base_delay_sec = PropertyDefinition::SECRET_ROTATION_RETRY_BASE_DELAY_SEC.get_float(props)
116
+
117
+ service_container.storage_service.register(
118
+ SECRETS_MANAGER_CACHE_NAME,
119
+ ttl: SECRET_CACHE_DISPOSAL_SEC
120
+ )
121
+
122
+ @subscribed_methods = SUBSCRIBED_METHODS
123
+ end
124
+
125
+ def connect(_host_info, driver_props, _is_initial_connection, pipeline_callable)
126
+ secrets_connect(driver_props, pipeline_callable)
127
+ end
128
+
129
+ def internal_connect(_host_info, driver_props, _, _is_initial_connection, pipeline_callable)
130
+ secrets_connect(driver_props, pipeline_callable)
131
+ end
132
+
133
+ private
134
+
135
+ def secrets_connect(driver_props, pipeline_callable)
136
+ if @rotation_retry_timeout_sec.positive?
137
+ connect_with_rotation_budget(driver_props, pipeline_callable)
138
+ else
139
+ connect_with_single_retry(driver_props, pipeline_callable)
140
+ end
141
+ end
142
+
143
+ # Default behavior (budget disabled): at most one forced re-fetch, and only when
144
+ # the *cached* secret failed to log in.
145
+ def connect_with_single_retry(driver_props, pipeline_callable)
146
+ secret_is_fresh = fetch_secret_and_report_if_fresh?(force: false)
147
+ apply_secret(driver_props)
148
+
149
+ begin
150
+ return pipeline_callable.call
151
+ rescue StandardError => e
152
+ raise unless !secret_is_fresh && @service_container.dialect_service.login_error?(e)
153
+ end
154
+
155
+ fetch_secret_and_report_if_fresh?(force: true)
156
+ apply_secret(driver_props)
157
+ pipeline_callable.call
158
+ end
159
+
160
+ # Budgeted behavior: force a re-fetch + reconnect with capped exponential backoff
161
+ # until login succeeds or the time budget is exhausted. Bridges the rotation window
162
+ # including the cold-cache first connection, and tolerates transient fetch failures.
163
+ def connect_with_rotation_budget(driver_props, pipeline_callable)
164
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + @rotation_retry_timeout_sec
165
+ delay_sec = @rotation_retry_base_delay_sec
166
+ last_login_error = nil
167
+ attempt = 0
168
+
169
+ loop do
170
+ attempt += 1
171
+
172
+ begin
173
+ # Attempt 1 may use the cache; every later attempt forces a re-fetch to pick up
174
+ # a version promoted in the meantime.
175
+ fetch_secret_and_report_if_fresh?(force: attempt > 1)
176
+ rescue StandardError => e
177
+ raise e if last_login_error.nil?
178
+
179
+ # Otherwise a transient Secrets Manager failure must not consume the budget;
180
+ # keep the login error as the reported cause and try again.
181
+ logger.debug("SecretsManagerPlugin: re-fetch failed mid-retry (#{e.class}: #{e.message}); keeping login error")
182
+ else
183
+ apply_secret(driver_props)
184
+ begin
185
+ connection = pipeline_callable.call
186
+ logger.info("SecretsManagerPlugin: connection succeeded on attempt #{attempt}") if attempt > 1
187
+ return connection
188
+ rescue StandardError => e
189
+ # Not a credentials problem -> re-fetching would not help.
190
+ raise unless @service_container.dialect_service.login_error?(e)
191
+
192
+ last_login_error = e
193
+ end
194
+ end
195
+
196
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
197
+ if remaining <= 0
198
+ logger.info("SecretsManagerPlugin: rotation retry budget exhausted after #{attempt} attempt(s)")
199
+ raise last_login_error
200
+ end
201
+
202
+ sleep([delay_sec, remaining].min)
203
+ delay_sec = [delay_sec * 2, MAX_RETRY_DELAY_SEC].min
204
+ end
205
+ end
206
+
207
+ # Returns true if credentials were freshly fetched from the service (suppresses login-error retry).
208
+ def fetch_secret_and_report_if_fresh?(force:)
209
+ credentials = Utils::AwsCredentialsUtils.snapshot(@credentials_provider)
210
+ cache_key = secret_cache_key(credentials)
211
+ entry = cache_key && @service_container.storage_service.get(SECRETS_MANAGER_CACHE_NAME, cache_key)
212
+
213
+ if entry.nil? || force
214
+ @secret = fetch_synchronously(cache_key, credentials)
215
+ return !@secret.nil?
216
+ end
217
+
218
+ if entry.expired?
219
+ logger.debug('SecretsManagerPlugin: serving stale credentials, refreshing in background')
220
+ @secret = entry
221
+ trigger_async_refresh(cache_key, credentials)
222
+ return false
223
+ end
224
+
225
+ @secret = entry
226
+ false
227
+ end
228
+
229
+ # The cache key for a secret read with the given credentials snapshot. Built per fetch, so it
230
+ # follows credentials that refresh to a new access key. Nil when there are no credentials:
231
+ # without them there is nothing to tell one connection's secret from another's, so such a
232
+ # connection neither reads nor writes the shared cache, and does not join another connection's
233
+ # in-flight fetch.
234
+ def secret_cache_key(credentials)
235
+ return nil if credentials.nil?
236
+
237
+ self.class.cache_key(@secret_id, @region, @endpoint, Utils::AwsCredentialsUtils.identity(credentials))
238
+ end
239
+
240
+ def fetch_synchronously(cache_key, credentials)
241
+ future =
242
+ if cache_key
243
+ trigger_async_refresh(cache_key, credentials)
244
+ else
245
+ Concurrent::Promises.future_on(:io) { fetch_and_store_secret(nil, credentials) }
246
+ end
247
+ future.value!(SYNC_FETCH_TIMEOUT_SEC)
248
+ rescue Concurrent::CancelledOperationError, Timeout::Error
249
+ raise Errors::SecretsManagerAuthError,
250
+ "Timed out fetching secret after #{SYNC_FETCH_TIMEOUT_SEC}s"
251
+ end
252
+
253
+ def trigger_async_refresh(cache_key, credentials)
254
+ pending = self.class.pending_refreshes
255
+
256
+ existing = pending[cache_key]
257
+ return existing if existing && !existing.resolved?
258
+
259
+ future_candidate = Concurrent::Promises.delay_on(:io) { fetch_and_store_secret(cache_key, credentials) }
260
+ stored = pending.put_if_absent(cache_key, future_candidate)
261
+ return stored if stored
262
+
263
+ future_candidate.touch
264
+
265
+ future_candidate.on_resolution! do |_fulfilled, _value, reason|
266
+ pending.delete_pair(cache_key, future_candidate)
267
+ logger.debug("SecretsManagerPlugin: async refresh failed: #{reason}") if reason
268
+ end
269
+
270
+ future_candidate
271
+ end
272
+
273
+ # Fetches the secret with the same credentials snapshot its cache key was built from and, when
274
+ # there is a cache key, caches it.
275
+ def fetch_and_store_secret(cache_key, credentials)
276
+ response = build_secrets_client(credentials).get_secret_value(secret_id: @secret_id)
277
+ parsed = parse_secret_string(response.secret_string)
278
+
279
+ unless parsed.is_a?(Hash) && parsed.key?(@username_key) && parsed.key?(@password_key)
280
+ raise Errors::SecretsManagerAuthError,
281
+ "Secret JSON missing required keys: '#{@username_key}' and/or '#{@password_key}'"
282
+ end
283
+
284
+ entry = SecretEntry.new(
285
+ username: parsed[@username_key],
286
+ password: parsed[@password_key],
287
+ expires_at: Process.clock_gettime(Process::CLOCK_MONOTONIC) + @expiration_sec
288
+ )
289
+ @service_container.storage_service.set(SECRETS_MANAGER_CACHE_NAME, cache_key, entry) if cache_key
290
+ entry
291
+ end
292
+
293
+ # Parse the raw secret string as JSON. A plaintext (non-JSON) secret makes
294
+ # +JSON.parse+ raise a +JSON::ParserError+. Catch that error and re-raise a
295
+ # +SecretsManagerAuthError+ with a message that never includes the secret.
296
+ def parse_secret_string(secret_string)
297
+ JSON.parse(secret_string)
298
+ rescue JSON::ParserError
299
+ raise Errors::SecretsManagerAuthError,
300
+ 'The secret is not in the expected JSON format. Ensure the secret stored in AWS ' \
301
+ 'Secrets Manager is a JSON object containing the configured username and password keys.'
302
+ end
303
+
304
+ def apply_secret(driver_props)
305
+ raise Errors::SecretsManagerAuthError, 'Failed to fetch database credentials from AWS Secrets Manager' unless @secret
306
+
307
+ user_key = @service_container.dialect_service.driver_dialect.user_property_key
308
+ driver_props[user_key] = @secret.username
309
+ driver_props[:password] = @secret.password
310
+ end
311
+
312
+ # A client for one fetch, signing with the given credentials snapshot. Without a snapshot the
313
+ # provider is passed as is, and the request fails for lack of credentials. Fetches only happen
314
+ # on a cache miss or refresh, so building a client for each one costs little.
315
+ def build_secrets_client(credentials)
316
+ opts = { region: @region, credentials: credentials || @credentials_provider }
317
+ opts[:endpoint] = @endpoint if @endpoint
318
+ Aws::SecretsManager::Client.new(**opts)
319
+ end
320
+
321
+ def resolve_region(props)
322
+ explicit = PropertyDefinition::SECRET_REGION.get(props)
323
+ return explicit if explicit
324
+
325
+ match = SECRETS_ARN_PATTERN.match(@secret_id.to_s)
326
+ match[:region] if match
327
+ end
328
+
329
+ # The logical expiration must leave room for the stale-while-revalidate window: an entry is
330
+ # physically removed SECRET_CACHE_DISPOSAL_SEC after it was fetched, so once it expires it can
331
+ # only be served stale until then. Clamp it to the range [MIN_EXPIRATION_SEC, MAX_EXPIRATION_SEC],
332
+ # where MAX_EXPIRATION_SEC reserves SWR_REVALIDATION_BUDGET_SEC before disposal for the refresh to complete.
333
+ def resolve_expiration(props)
334
+ configured = PropertyDefinition::SECRET_EXPIRATION_SEC.get_float(props)
335
+ if configured < MIN_EXPIRATION_SEC
336
+ logger.warn("SecretsManagerPlugin: expiration #{configured}s below minimum #{MIN_EXPIRATION_SEC}s, clamping")
337
+ MIN_EXPIRATION_SEC
338
+ elsif configured > MAX_EXPIRATION_SEC
339
+ logger.warn("SecretsManagerPlugin: expiration #{configured}s exceeds the #{MAX_EXPIRATION_SEC}s maximum " \
340
+ "(leaving #{SWR_REVALIDATION_BUDGET_SEC}s before the #{SECRET_CACHE_DISPOSAL_SEC}s cache " \
341
+ 'lifetime cap for stale-while-revalidate), clamping')
342
+ MAX_EXPIRATION_SEC
343
+ else
344
+ configured
345
+ end
346
+ end
347
+
348
+ def ensure_sdk!
349
+ require 'aws-sdk-secretsmanager'
350
+ require 'json'
351
+ rescue LoadError
352
+ raise LoadError,
353
+ "The Secrets Manager plugin requires 'aws-sdk-secretsmanager'. " \
354
+ "Add it to your Gemfile: gem 'aws-sdk-secretsmanager'"
355
+ end
356
+ end
357
+ end
358
+ end