kessel-sdk 1.11.0 → 1.13.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6f118f8d4a64bb88e16061100917338de50a57456725240592395e4e9469eb18
4
- data.tar.gz: 0cdba6a003c929eb0e111ef832fdc602b13a0dc48dc52ea773522c096408cea8
3
+ metadata.gz: 6a800ed3c85f59d9a3c3693b03a930d0c15c3f9469afcf7415439c1df6ce0ed5
4
+ data.tar.gz: 7af05d6be0c40cac48b26ce2a4e62168561b9b8291bbb320984676cc1a95385c
5
5
  SHA512:
6
- metadata.gz: d172c0dc4223a7aca090c58a14ab2c86421383ad1fc0c7931690f576fa1d88130bde656b495455f0fcd91c331d4f2dcb935963a3f9f7a5708640849102d26ddd
7
- data.tar.gz: 1112d3a51e7c23f00371d88adf7cc17e8323e20d4d80663272f7a55c87e52c5b50374582c2c203a75d14954773b0ebb982c4df79553a067edcfc737d44721eb9
6
+ metadata.gz: a2606225a968c861b8097c89f11076e111b48f2873cddcdce7784bb08e67c51df5cc7ef313686ebf4991c3d357323f8e35d751e68be2af27130aaa9faed99915
7
+ data.tar.gz: f9707c137cffaf0d86546da1986078baa60c887adde5e65835403e8fecd6f19ce3b781a9330c59455da3046e6cab80ef3163aea01b01cfad6e786657b5415689
data/README.md CHANGED
@@ -97,7 +97,8 @@ discovery = fetch_oidc_discovery('https://sso.example.com/auth/realms/my-realm')
97
97
  oauth = OAuth2ClientCredentials.new(
98
98
  client_id: 'my-app',
99
99
  client_secret: 'my-secret',
100
- token_endpoint: discovery.token_endpoint
100
+ token_endpoint: discovery.token_endpoint,
101
+ retry: { max_retries: 3, base_delay: 0.5, max_delay: 2.0, jitter: :full }
101
102
  )
102
103
 
103
104
  # Build the client -- tokens are cached and refreshed automatically
@@ -106,6 +107,13 @@ client = KesselInventoryService::ClientBuilder.new('kessel.example.com:443')
106
107
  .build
107
108
  ```
108
109
 
110
+ Token fetch retries apply only to the token endpoint, not OIDC discovery. By default, network/timeout errors and HTTP
111
+ 429/5xx responses receive four total attempts (initial plus three retries), with full-jitter delay caps of 0.5, 1,
112
+ and 2 seconds and total added sleep below 3.5 seconds. `retry` accepts non-negative integer `max_retries` (0
113
+ disables retries), positive finite-second `base_delay` and `max_delay`, and `jitter: :full` or `:none`; defaults are
114
+ `3`, `0.5`, `2.0`, and `:full`. Invalid retry configuration raises `ArgumentError`. Permanent OAuth/configuration
115
+ failures are not retried. Refresh is synchronous, so a transient SSO failure may delay an RPC.
116
+
109
117
  #### Custom or No Credentials
110
118
 
111
119
  ```ruby
@@ -122,6 +130,31 @@ client = KesselInventoryService::ClientBuilder.new(target)
122
130
 
123
131
  Build the client **once at application startup and reuse it**. The underlying gRPC channel manages its own HTTP/2 connection pool.
124
132
 
133
+ ### HTTP/2 Keepalive
134
+
135
+ All inventory clients now use these transport keepalive defaults, including clients created by existing builder chains:
136
+
137
+ - Ping interval: 45 seconds
138
+ - Ping acknowledgement timeout: 10 seconds
139
+ - Permit pings while there are no active calls: enabled
140
+
141
+ Override these values with the fluent `keepalive` method. Durations are finite positive real numbers in seconds and
142
+ are converted to integer milliseconds by truncating any fractional millisecond downward. The resulting value must be
143
+ between 1 and 2,147,483,647 milliseconds. Passing `nil` leaves that setting unchanged; `permit_without_calls` accepts
144
+ only `true` or `false` (including `false` as an explicit override).
145
+
146
+ ```ruby
147
+ client = Kessel::Inventory::V1beta2::KesselInventoryService::ClientBuilder
148
+ .new(ENV.fetch('KESSEL_ENDPOINT', nil))
149
+ .keepalive(interval: 60, timeout: 10, permit_without_calls: false)
150
+ .build
151
+ ```
152
+
153
+ The Ruby SDK sets `grpc.http2.max_pings_without_data` internally to avoid a local cap; it is not a public configuration
154
+ option. Before rollout, confirm that the Kessel server and any gateway enforce compatible keepalive policies
155
+ (RHCLOUD-51673). Keepalive is a transport-level ping mechanism, not an RPC health check and does not provide retries
156
+ or load-balancer guarantees. The builder still returns a gRPC stub; build it once and reuse it as usual.
157
+
125
158
  ### Check Permissions
126
159
 
127
160
  ```ruby
@@ -339,6 +372,7 @@ The `examples/` directory contains working examples. Set up environment variable
339
372
  | `check_for_update_bulk.rb` | Bulk strongly consistent update checks |
340
373
  | `delete_resource.rb` | Deleting resources |
341
374
  | `fetch_workspaces.rb` | Fetching workspaces via RBAC HTTP API |
375
+ | `keepalive.rb` | Configuring HTTP/2 keepalive on an inventory client |
342
376
  | `list_workspaces.rb` | Listing workspaces with auto-pagination |
343
377
  | `report_resource.rb` | Reporting resource state |
344
378
  | `console_principal.rb` | Building principals from `x-rh-identity` headers |
data/lib/kessel/auth.rb CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  require 'grpc'
4
4
  require 'kessel/version'
5
+ require 'socket'
6
+ require 'timeout'
5
7
 
6
8
  module Kessel
7
9
  # OpenID Connect authentication module for Kessel services.
@@ -105,6 +107,7 @@ module Kessel
105
107
  #
106
108
  # # Get current access token (automatically cached and refreshed)
107
109
  # token = oauth.get_token
110
+ # rubocop:disable Metrics/ClassLength
108
111
  class OAuth2ClientCredentials
109
112
  include Kessel::Auth
110
113
 
@@ -113,9 +116,14 @@ module Kessel
113
116
  # @param client_id [String] OIDC client identifier
114
117
  # @param client_secret [String] OIDC client secret
115
118
  # @param token_endpoint [String] OIDC token endpoint URL
119
+ # @param retry [Hash] Optional token-endpoint retry settings. Keys are
120
+ # `max_retries` (non-negative Integer; 0 disables retries), `base_delay`
121
+ # and `max_delay` (positive finite seconds), and `jitter` (`:full` or
122
+ # `:none`). Defaults are 3, 0.5, 2.0, and `:full`, respectively.
116
123
  #
117
124
  # @raise [OAuthDependencyError] if the openid_connect gem is not available
118
125
  # @raise [OAuthAuthenticationError] if authentication fails
126
+ # @raise [ArgumentError] if retry settings are invalid
119
127
  #
120
128
  # @example
121
129
  # oauth = OAuth2ClientCredentials.new(
@@ -123,13 +131,20 @@ module Kessel
123
131
  # client_secret: 'secret',
124
132
  # token_endpoint: 'https://my-domain/auth/realms/my-realm/protocol/openid-connect/token'
125
133
  # )
126
- def initialize(client_id:, client_secret:, token_endpoint:)
134
+ def initialize(client_id:, client_secret:, token_endpoint:, **options)
135
+ validate_retry_options!(options)
136
+ retry_config = normalize_retry_config(options.fetch(:retry, {}))
127
137
  check_dependencies!
128
138
 
129
139
  @client_id = client_id
130
140
  @client_secret = client_secret
131
141
  @token_endpoint = token_endpoint
142
+ @retry_config = retry_config
132
143
  @token_mutex = Mutex.new
144
+ @generation_state_mutex = Mutex.new
145
+ @generation_users = Hash.new(0)
146
+ @generation_failures = {}
147
+ @cached_token_generation = nil
133
148
  @generation = 0
134
149
  end
135
150
 
@@ -147,23 +162,80 @@ module Kessel
147
162
  def get_token(force_refresh: false)
148
163
  return @cached_token if !force_refresh && token_valid?
149
164
 
150
- generation = @generation
165
+ generation = register_generation
151
166
 
152
- @token_mutex.synchronize do
153
- # Another thread already refreshed while we waited on the lock
154
- return @cached_token if @generation != generation && token_valid?
167
+ begin
168
+ @token_mutex.synchronize do
169
+ failure = generation_failure(generation)
155
170
 
156
- @cached_token = refresh
157
- @generation += 1
171
+ # A later successful generation may have recovered after this caller's failure.
172
+ return @cached_token if newer_cached_token?(generation)
173
+ raise failure if failure
174
+
175
+ begin
176
+ token = refresh
177
+ rescue StandardError => e
178
+ record_failure_and_advance(generation, e)
179
+ raise
180
+ end
181
+ cache_token_and_advance(token)
158
182
 
159
- return @cached_token
183
+ @cached_token
184
+ end
160
185
  rescue StandardError => e
161
- raise OAuthAuthenticationError, "Failed to obtain client credentials token: #{e.message}"
186
+ raise OAuthAuthenticationError, "Failed to obtain client credentials token: #{e.message}", cause: e
187
+ ensure
188
+ unregister_generation(generation)
162
189
  end
163
190
  end
164
191
 
165
192
  private
166
193
 
194
+ def register_generation
195
+ @generation_state_mutex.synchronize do
196
+ generation = @generation
197
+ @generation_users[generation] += 1
198
+ generation
199
+ end
200
+ end
201
+
202
+ def generation_failure(generation)
203
+ @generation_state_mutex.synchronize { @generation_failures[generation] }
204
+ end
205
+
206
+ def record_failure_and_advance(generation, error)
207
+ @generation_state_mutex.synchronize do
208
+ @generation_failures[generation] = error
209
+ @generation += 1
210
+ end
211
+ end
212
+
213
+ def advance_generation
214
+ @generation_state_mutex.synchronize { @generation += 1 }
215
+ end
216
+
217
+ def cache_token_and_advance(token)
218
+ @generation_state_mutex.synchronize do
219
+ @cached_token = token
220
+ @generation += 1
221
+ @cached_token_generation = @generation
222
+ end
223
+ end
224
+
225
+ def newer_cached_token?(generation)
226
+ !!(@cached_token_generation && @cached_token_generation > generation && token_valid?)
227
+ end
228
+
229
+ def unregister_generation(generation)
230
+ @generation_state_mutex.synchronize do
231
+ @generation_users[generation] -= 1
232
+ next unless @generation_users[generation].zero?
233
+
234
+ @generation_users.delete(generation)
235
+ @generation_failures.delete(generation)
236
+ end
237
+ end
238
+
167
239
  def refresh
168
240
  client = create_oidc_client
169
241
 
@@ -173,13 +245,117 @@ module Kessel
173
245
  client_secret: @client_secret
174
246
  }
175
247
 
176
- token_data = client.access_token!(request_params)
248
+ token_data = access_token_with_retries(client, request_params)
177
249
  RefreshTokenResponse.new(
178
250
  access_token: token_data.access_token,
179
251
  expires_at: Time.now + (token_data.expires_in || DEFAULT_EXPIRES_IN)
180
252
  ).freeze
181
253
  end
182
254
 
255
+ def access_token_with_retries(client, request_params)
256
+ retry_index = 0
257
+
258
+ begin
259
+ client.access_token!(request_params)
260
+ rescue StandardError => e
261
+ raise unless retryable_token_error?(e) && retry_index < @retry_config[:max_retries]
262
+
263
+ sleep(retry_delay(retry_index))
264
+ retry_index += 1
265
+ retry
266
+ end
267
+ end
268
+
269
+ def validate_retry_options!(options)
270
+ unknown_keys = options.keys - [:retry]
271
+ return if unknown_keys.empty?
272
+
273
+ raise ArgumentError, "unknown keyword: #{unknown_keys.first.inspect}"
274
+ end
275
+
276
+ def normalize_retry_config(retry_options)
277
+ unless retry_options.is_a?(Hash) && retry_options.keys.all?(Symbol)
278
+ raise ArgumentError, 'retry must be a symbol-keyed Hash'
279
+ end
280
+
281
+ unknown_keys = retry_options.keys - %i[max_retries base_delay max_delay jitter]
282
+ raise ArgumentError, "unknown retry option: #{unknown_keys.first.inspect}" unless unknown_keys.empty?
283
+
284
+ config = {
285
+ max_retries: 3,
286
+ base_delay: 0.5,
287
+ max_delay: 2.0,
288
+ jitter: :full
289
+ }.merge(retry_options)
290
+ validate_retry_config_values!(config)
291
+ config.freeze
292
+ end
293
+
294
+ def validate_retry_config_values!(config)
295
+ unless config[:max_retries].is_a?(Integer) && config[:max_retries] >= 0
296
+ raise ArgumentError, 'retry max_retries must be a non-negative Integer'
297
+ end
298
+
299
+ %i[base_delay max_delay].each do |key|
300
+ next if valid_delay?(config[key])
301
+
302
+ raise ArgumentError, "retry #{key} must be a positive finite Integer or Float"
303
+ end
304
+
305
+ return if %i[full none].include?(config[:jitter])
306
+
307
+ raise ArgumentError, 'retry jitter must be :full or :none'
308
+ end
309
+
310
+ def valid_delay?(value)
311
+ (value.is_a?(Integer) || value.is_a?(Float)) && value.positive? &&
312
+ (!value.is_a?(Float) || value.finite?)
313
+ end
314
+
315
+ def retry_delay(retry_index)
316
+ cap = [@retry_config[:max_delay], @retry_config[:base_delay] * (2**retry_index)].min
317
+ return cap if @retry_config[:jitter] == :none
318
+
319
+ rand(cap.to_f)
320
+ end
321
+
322
+ def retryable_token_error?(error)
323
+ retryable_rack_oauth_error?(error) || retryable_transient_error?(error)
324
+ end
325
+
326
+ def retryable_rack_oauth_error?(error)
327
+ return false unless defined?(::Rack::OAuth2::Client::Error)
328
+ return false unless error.is_a?(::Rack::OAuth2::Client::Error)
329
+
330
+ status = error.status
331
+ status == 429 || (status.is_a?(Integer) && status.between?(500, 599))
332
+ end
333
+
334
+ def retryable_transient_error?(error)
335
+ transient_error_classes.any? { |error_class| error.is_a?(error_class) }
336
+ end
337
+
338
+ def transient_error_classes
339
+ %w[
340
+ Faraday::ConnectionFailed
341
+ Faraday::TimeoutError
342
+ Timeout::Error
343
+ SocketError
344
+ EOFError
345
+ Errno::ECONNREFUSED
346
+ Errno::ECONNRESET
347
+ Errno::ETIMEDOUT
348
+ Errno::EHOSTUNREACH
349
+ Errno::ENETUNREACH
350
+ ].map { |name| optional_error_class(name) }.compact
351
+ end
352
+
353
+ def optional_error_class(name)
354
+ Object.const_get(name, false)
355
+ rescue NameError
356
+ nil
357
+ end
358
+
183
359
  # Checks if we have a valid cached token.
184
360
  #
185
361
  # @return [Boolean] true if token exists and not expired
@@ -206,5 +382,6 @@ module Kessel
206
382
  )
207
383
  end
208
384
  end
385
+ # rubocop:enable Metrics/ClassLength
209
386
  end
210
387
  end
@@ -59,6 +59,25 @@ client = KesselInventoryService::ClientBuilder.new(target)
59
59
 
60
60
  `build` returns a single gRPC stub instance (not a tuple). The underlying gRPC channel manages its own HTTP/2 connection pool. Build once at application startup and reuse -- do not create a new stub per request.
61
61
 
62
+ ### HTTP/2 Keepalive
63
+
64
+ Every `ClientBuilder` passes these channel arguments to its service stub, including builders that do not call `keepalive`:
65
+
66
+ - `grpc.keepalive_time_ms`: 45,000 (45 seconds)
67
+ - `grpc.keepalive_timeout_ms`: 10,000 (10 seconds)
68
+ - `grpc.keepalive_permit_without_calls`: 1 (enabled)
69
+ - `grpc.http2.max_pings_without_data`: 0 (internal Ruby gRPC setting; not exposed as a public option)
70
+
71
+ `keepalive(interval: nil, timeout: nil, permit_without_calls: nil)` is fluent. Duration keywords are finite positive real
72
+ numbers in seconds, converted to integer milliseconds by truncating fractional milliseconds downward; the result must
73
+ be 1 through 2,147,483,647. `nil` leaves the existing setting unchanged, and `permit_without_calls` accepts only the
74
+ actual boolean values `true` and `false`. Each call validates every supplied value before changing any builder state.
75
+
76
+ These are transport ping settings, not RPC health checks, retries, or load-balancer guarantees. Check server and gateway
77
+ keepalive enforcement compatibility before rollout (RHCLOUD-51673); in particular, the 45-second interval may be
78
+ affected by an infrastructure-enforced minimum. Ruby's `grpc.http2.max_pings_without_data` value is internal and is not a
79
+ cross-SDK configuration knob.
80
+
62
81
  ## Service Wiring Pattern
63
82
 
64
83
  Every gRPC service module must follow this exact pattern:
@@ -14,9 +14,24 @@ module Kessel
14
14
  class ClientBuilder
15
15
  include Kessel::GRPC
16
16
 
17
+ MAX_KEEPALIVE_MILLISECONDS = 2_147_483_647
18
+ private_constant :MAX_KEEPALIVE_MILLISECONDS
19
+
17
20
  def initialize(target)
18
21
  @target = target
19
22
  raise 'Invalid target type' if @target.nil? || !@target.is_a?(String)
23
+
24
+ @channel_args = {
25
+ 'grpc.keepalive_time_ms' => 45_000,
26
+ 'grpc.keepalive_timeout_ms' => 10_000,
27
+ 'grpc.keepalive_permit_without_calls' => 1,
28
+ 'grpc.http2.max_pings_without_data' => 0
29
+ }
30
+ end
31
+
32
+ def keepalive(interval: nil, timeout: nil, permit_without_calls: nil)
33
+ @channel_args.merge!(keepalive_channel_args(interval, timeout, permit_without_calls))
34
+ self
20
35
  end
21
36
 
22
37
  def oauth2_client_authenticated(oauth2_client_credentials:, channel_credentials: nil)
@@ -52,7 +67,7 @@ module Kessel
52
67
 
53
68
  credentials = @channel_credentials
54
69
  credentials = credentials.compose(@call_credentials) unless @call_credentials.nil?
55
- self.class.stub_class.new(@target, credentials)
70
+ self.class.stub_class.new(@target, credentials, channel_args: @channel_args.dup)
56
71
  end
57
72
 
58
73
  private
@@ -66,6 +81,41 @@ module Kessel
66
81
 
67
82
  raise 'Invalid credential configuration: can not authenticate with insecure channel'
68
83
  end
84
+
85
+ def keepalive_channel_args(interval, timeout, permit_without_calls)
86
+ channel_args = {}
87
+ channel_args['grpc.keepalive_time_ms'] = duration_to_milliseconds(interval, 'interval') unless interval.nil?
88
+ channel_args['grpc.keepalive_timeout_ms'] = duration_to_milliseconds(timeout, 'timeout') unless timeout.nil?
89
+
90
+ unless permit_without_calls.nil?
91
+ unless permit_without_calls.equal?(true) || permit_without_calls.equal?(false)
92
+ raise 'Invalid keepalive permit_without_calls: must be true, false, or nil'
93
+ end
94
+
95
+ channel_args['grpc.keepalive_permit_without_calls'] = permit_without_calls ? 1 : 0
96
+ end
97
+
98
+ channel_args
99
+ end
100
+
101
+ def duration_to_milliseconds(value, name)
102
+ unless value.is_a?(Numeric) && !value.is_a?(Complex) && value.respond_to?(:finite?) && value.finite?
103
+ raise "Invalid keepalive #{name}: must be a finite real number of seconds"
104
+ end
105
+
106
+ scaled_milliseconds = value * 1000
107
+ unless valid_scaled_keepalive_milliseconds?(scaled_milliseconds)
108
+ raise "Invalid keepalive #{name}: must convert to 1..#{MAX_KEEPALIVE_MILLISECONDS} milliseconds"
109
+ end
110
+
111
+ scaled_milliseconds.floor
112
+ end
113
+
114
+ def valid_scaled_keepalive_milliseconds?(milliseconds)
115
+ milliseconds.respond_to?(:finite?) &&
116
+ milliseconds.finite? &&
117
+ (1...(MAX_KEEPALIVE_MILLISECONDS + 1)).cover?(milliseconds)
118
+ end
69
119
  end
70
120
  end
71
121
  end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Kessel
4
4
  module Inventory
5
- VERSION = '1.11.0'
5
+ VERSION = '1.13.0'
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kessel-sdk
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.11.0
4
+ version: 1.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Project Kessel
@@ -314,7 +314,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
314
314
  - !ruby/object:Gem::Version
315
315
  version: '0'
316
316
  requirements: []
317
- rubygems_version: 4.0.7
317
+ rubygems_version: 4.0.22
318
318
  specification_version: 4
319
319
  summary: Ruby SDK for Project Kessel
320
320
  test_files: []