Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions app/lib/katello/resources/candlepin.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ module Candlepin
TOTAL_COUNT_HEADER = :x_total_count # as parsed by rest_client

class CandlepinResource < HttpResource
include Candlepin::PersistentConnection

cfg = SETTINGS[:katello][:candlepin]
url = cfg[:url]
uri = URI.parse(url)
Expand Down Expand Up @@ -83,6 +85,7 @@ def self.fetch_paged(page_size = -1)
class UpstreamCandlepinResource < CandlepinResource
extend ::Katello::Util::HttpProxy

self.use_persistent_connection = false
self.prefix = '/subscription'

class << self
Expand Down
129 changes: 129 additions & 0 deletions app/lib/katello/resources/candlepin/persistent_connection.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
require 'net/http/persistent'
Comment thread
coderabbitai[bot] marked this conversation as resolved.
require 'ostruct'

module Katello
module Resources
module Candlepin
# Provides persistent HTTP connection pooling for CandlepinResource.
#
# Net::HTTP::Persistent maintains one TCP+TLS connection per thread,
# reused across all Candlepin calls within that thread. Stale connections
# are detected and re-established automatically by the library.
#
# Include in CandlepinResource; set `self.use_persistent_connection = false`
# in subclasses that connect to different servers (e.g. UpstreamCandlepinResource).
module PersistentConnection
extend ActiveSupport::Concern

# Response wrapper that preserves the RestClient::Response contract.
#
# Inherits from String (like RestClient::Response) so that
# MultiJson.load(exception.response) works in CandlepinError.from_exception.
class PooledResponse < String
attr_reader :code, :headers, :request

def initialize(net_response, url:)
super(net_response.body || '')
@code = net_response.code.to_i
@headers = {}
net_response.each_header do |k, v|
@headers[k.downcase.tr('-', '_').to_sym] = v
end
@request = OpenStruct.new(url: url)
end

def body
to_s
end
end

# Translate RestClient-style symbol values to proper MIME types.
# RestClient converts {:accept => :json} to "Accept: application/json",
# but Net::HTTP passes symbols literally ("Accept: json") which Candlepin rejects.
CONTENT_TYPE_MAP = {json: 'application/json', xml: 'application/xml'}.freeze

included do
class_attribute :use_persistent_connection, default: true
end

class_methods do
def persistent_http
@persistent_http ||= begin
http = Net::HTTP::Persistent.new(name: 'candlepin')
http.idle_timeout = 30
http.max_requests = 500
http.ca_file = ssl_ca_file if ssl_ca_file
http.open_timeout = SETTINGS[:katello][:rest_client_timeout]
http.read_timeout = SETTINGS[:katello][:rest_client_timeout]
http
end
end

def issue_request(method:, path:, headers: {}, payload: nil)
return super unless use_persistent_connection

url = self.site + path
uri = URI.parse(url)

request = Katello::HttpResource::REQUEST_MAP[method].new(uri)

# OAuth signing — same consumer/secret as rest_client(), applied to the actual request
consumer = OAuth::Consumer.new(
self.consumer_key, self.consumer_secret,
site: self.site, http_method: method,
request_token_path: '', authorize_path: '', access_token_path: ''
)
consumer.sign!(request)

(headers || {}).each do |k, v|
request[k.to_s] = CONTENT_TYPE_MAP[v] || v.to_s
end

if payload
request.body = payload
request.content_type = 'application/json' unless request['Content-Type']
end
Comment on lines +78 to +85

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Ruby's Net::HTTP, if a header is assigned with request['content_type'] = 'application/json', is it preserved as content_type/Content_typeon the wire, or normalized toContent-Type?

💡 Result:

In Ruby's Net::HTTP, when you assign a header using request['content_type'] = 'application/json', the key is stored in lowercase as 'content_type' in the internal @header hash because Net::HTTPHeader#[]=(key, val) does key.downcase before storing [1][2][3][4][5]. When the request is sent on the wire, Net::HTTP uses each_header, which iterates over @header.each do |k,va| yield k, va.join(', ') end, sending the header names in their stored lowercase form (e.g., "content_type: application/json") [3]. There is a separate capitalize method in Net::HTTPHeader that title-cases header names (e.g., "Content-Type") by splitting on '-' and capitalizing each word [6], but it is used in iteration methods like each_capitalized_name (deprecated) or each_capitalized_name, not in the actual request writing process [3][7][4][6]. Examples confirm lowercase transmission unless custom classes override to preserve case [8][1]. Thus, it is preserved as 'content_type' on the wire, not normalized to 'Content-Type'. HTTP header names are case-insensitive per RFC, so this is valid [9].

Citations:


🏁 Script executed:

# First, find the exact file and read the relevant section
find . -type f -name "persistent_connection.rb" | head -5

Repository: Katello/katello

Length of output: 122


🏁 Script executed:

# Get context around lines 77-84 to understand the full picture
if [ -f "app/lib/katello/resources/candlepin/persistent_connection.rb" ]; then
  wc -l "app/lib/katello/resources/candlepin/persistent_connection.rb"
fi

Repository: Katello/katello

Length of output: 124


🏁 Script executed:

# Read the file to see full method context
cat -n "app/lib/katello/resources/candlepin/persistent_connection.rb" | sed -n '60,100p'

Repository: Katello/katello

Length of output: 1922


🏁 Script executed:

# Look for tests related to persistent_connection
find . -type f -name "*persistent_connection*test*.rb" -o -name "*persistent_connection*spec*.rb" 2>/dev/null

Repository: Katello/katello

Length of output: 110


🏁 Script executed:

# Read the test file to understand what's being tested
cat -n "./test/lib/resources/persistent_connection_test.rb" | head -100

Repository: Katello/katello

Length of output: 4753


🏁 Script executed:

# Search for CONTENT_TYPE_MAP definition
rg "CONTENT_TYPE_MAP" -B 2 -A 5

Repository: Katello/katello

Length of output: 1666


🏁 Script executed:

# Search for how headers are passed in tests or throughout codebase
rg "issue_request.*headers" -B 1 -A 3

Repository: Katello/katello

Length of output: 4304


🏁 Script executed:

# Search for actual calls to issue_request with non-empty headers
rg "issue_request.*headers.*:" -A 2 | head -40

Repository: Katello/katello

Length of output: 2781


🏁 Script executed:

# Check if headers are ever passed with underscore style (like :accept, :content_type)
rg "headers\s*[:=].*\{.*[:_]" | head -20

Repository: Katello/katello

Length of output: 2244


🏁 Script executed:

# Look at RestClient usage and how it converts headers
grep -r "RestClient" app/lib/katello/resources/candlepin/ | grep -v ".pyc" | head -10

Repository: Katello/katello

Length of output: 1216


🏁 Script executed:

# Search for how headers are constructed or passed before issue_request
rg "accept|content_type|content-type" app/lib/katello/resources/candlepin/ -B 2 -A 2

Repository: Katello/katello

Length of output: 6775


Normalize header key underscores to hyphens for proper HTTP header formatting.

Headers passed with underscore symbols (:accept, :content_type) are converted to lowercase strings ("accept", "content_type") and sent on the wire in that form, not as proper HTTP headers ("Accept", "Content-Type"). This breaks the condition at line 83, which checks request['Content-Type'] (title-case) but won't match request['content_type'] (underscore) set by line 78. GET/DELETE requests won't have the Content-Type header set even when intended.

Suggested change
             (headers || {}).each do |k, v|
-              request[k.to_s] = CONTENT_TYPE_MAP[v] || v.to_s
+              request[k.to_s.tr('_', '-')] = CONTENT_TYPE_MAP[v] || v.to_s
             end
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/katello/resources/candlepin/persistent_connection.rb` around lines 77
- 84, Headers with underscores must be normalized to proper HTTP header names
before assignment so request['Content-Type'] checks work; in
persistent_connection.rb, when iterating (headers || {}).each do |k, v|,
transform the key (k) to replace underscores with hyphens and canonicalize
capitalization (e.g. "content_type" -> "Content-Type") then use that normalized
key when setting request[...] = CONTENT_TYPE_MAP[v] || v.to_s; this ensures
request['Content-Type'] is present when payload is set and the existing
payload/content_type logic continues to work.


logger.debug("Pooled Candlepin #{method.upcase} request: #{path}")
begin
logger.debug "Body: #{filter_sensitive_data(payload.to_json)}"
rescue JSON::GeneratorError, Encoding::UndefinedConversionError
logger.debug "Body: Error: could not render payload as json"
end if payload

net_response = persistent_http.request(uri, request)
response = PooledResponse.new(net_response, url: url)

if response.code >= 400
raise_pooled_error(response, path, method)
end

process_response(response)
rescue Errno::ECONNREFUSED
service = path.split("/").second
raise Errors::ConnectionRefusedException,
_("A backend service [ %s ] is unreachable") % service.capitalize
rescue Net::HTTP::Persistent::Error => e
raise Errors::ConnectionRefusedException,
_("A backend service [ Candlepin ] is unreachable: %s") % e.message
end

private

def raise_pooled_error(response, path, method)
unless response.headers[:x_version]
fail ::Katello::Errors::CandlepinNotRunning
end

exception_class = RestClient::Exceptions::EXCEPTIONS_MAP.fetch(
response.code, RestClient::ExceptionWithResponse
)
exception = exception_class.new(response, response.code)

raise_rest_client_exception(exception, path, method.to_s.upcase)
end
end
end
end
end
end
22 changes: 12 additions & 10 deletions app/lib/katello/resources/candlepin/proxy.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,32 @@ def self.logger

def self.post(path, body)
logger.debug "Sending POST request to Candlepin: #{path}"
client = CandlepinResource.rest_client(Net::HTTP::Post, :post, path_with_cp_prefix(path))
client.post body, {:accept => :json, :content_type => :json}.merge(User.cp_oauth_header)
CandlepinResource.post(path_with_cp_prefix(path), body,
{:accept => :json, :content_type => :json}.merge(User.cp_oauth_header))
Comment on lines +11 to +12

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reuse CandlepinResource.default_headers for these proxied calls.

These inline hashes bypass CandlepinResource.default_headers, so proxied requests stop sending accept-language and X-Correlation-ID. get also mutates the caller’s extra_headers via merge!. Reusing the shared header builder preserves the previous request contract and avoids the mutation.

Suggested change
         def self.post(path, body)
           logger.debug "Sending POST request to Candlepin: #{path}"
-          CandlepinResource.post(path_with_cp_prefix(path), body,
-            {:accept => :json, :content_type => :json}.merge(User.cp_oauth_header))
+          CandlepinResource.post(path_with_cp_prefix(path), body, CandlepinResource.default_headers)
         end

         def self.delete(path, body = nil)
           logger.debug "Sending DELETE request to Candlepin: #{path}"
-          headers = {:accept => :json, :content_type => :json}.merge(User.cp_oauth_header)
+          headers = CandlepinResource.default_headers
           if body
             CandlepinResource.delete(path_with_cp_prefix(path), body, headers)
           else
             CandlepinResource.delete(path_with_cp_prefix(path), headers)
           end
@@
         def self.get(path, extra_headers = {})
           logger.debug "Sending GET request to Candlepin: #{path}"
-          CandlepinResource.get(path_with_cp_prefix(path),
-            extra_headers.merge!(default_request_headers))
+          CandlepinResource.get(
+            path_with_cp_prefix(path),
+            CandlepinResource.default_headers.merge(extra_headers)
+          )
         rescue RestClient::NotModified => e
           e.response
         end

         def self.put(path, body)
           logger.debug "Sending PUT request to Candlepin: #{path}"
-          CandlepinResource.put(path_with_cp_prefix(path), body,
-            {:accept => :json, :content_type => :json}.merge(User.cp_oauth_header))
+          CandlepinResource.put(path_with_cp_prefix(path), body, CandlepinResource.default_headers)
         end

Also applies to: 17-21, 27-28, 35-36

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/lib/katello/resources/candlepin/proxy.rb` around lines 11 - 12, The
proxied calls currently pass inline header hashes which bypass
CandlepinResource.default_headers and one use of merge! mutates caller state;
update each CandlepinResource.post/get invocation (the calls using
path_with_cp_prefix(path) and similar at the noted locations) to build headers
by starting from CandlepinResource.default_headers and merging in
User.cp_oauth_header plus the content/accept entries (e.g. :accept => :json,
:content_type => :json) without using merge! so accept-language and
X-Correlation-ID are preserved and no external hash is mutated; ensure all
occurrences (including the get call that currently does merge!) use this
pattern.

end

def self.delete(path, body = nil)
logger.debug "Sending DELETE request to Candlepin: #{path}"
client = CandlepinResource.rest_client(Net::HTTP::Delete, :delete, path_with_cp_prefix(path))
# Some candlepin calls will set the body in DELETE requests.
client.options[:payload] = body unless body.nil?
client.delete({:accept => :json, :content_type => :json}.merge(User.cp_oauth_header))
headers = {:accept => :json, :content_type => :json}.merge(User.cp_oauth_header)
if body
CandlepinResource.delete(path_with_cp_prefix(path), body, headers)
else
CandlepinResource.delete(path_with_cp_prefix(path), headers)
end
end

def self.get(path, extra_headers = {})
logger.debug "Sending GET request to Candlepin: #{path}"
client = CandlepinResource.rest_client(Net::HTTP::Get, :get, path_with_cp_prefix(path))
client.get(extra_headers.merge!(default_request_headers))
CandlepinResource.get(path_with_cp_prefix(path),
extra_headers.merge!(default_request_headers))
rescue RestClient::NotModified => e
e.response
end

def self.put(path, body)
logger.debug "Sending PUT request to Candlepin: #{path}"
client = CandlepinResource.rest_client(Net::HTTP::Put, :put, path_with_cp_prefix(path))
client.put body, {:accept => :json, :content_type => :json}.merge(User.cp_oauth_header)
CandlepinResource.put(path_with_cp_prefix(path), body,
{:accept => :json, :content_type => :json}.merge(User.cp_oauth_header))
end

def self.path_with_cp_prefix(path)
Expand Down
1 change: 1 addition & 0 deletions katello.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ Gem::Specification.new do |gem|
gem.add_dependency "json"
gem.add_dependency "oauth"
gem.add_dependency "rest-client"
gem.add_dependency "net-http-persistent", ">= 4.0"

gem.add_dependency "rabl"
gem.add_dependency "foreman-tasks", ">= 12.0.0"
Expand Down
143 changes: 143 additions & 0 deletions test/lib/resources/persistent_connection_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
require 'katello_test_helper'

module Katello
module Resources
module Candlepin
class PooledResponseTest < ActiveSupport::TestCase
def setup
@net_response = stub(
:body => '{"id":"abc","name":"test"}',
:code => '200'
)
@net_response.stubs(:each_header).multiple_yields(
['X-Candlepin-Request-Uuid', 'req-123'],
['X-Version', '4.3.1'],
['Content-Type', 'application/json']
)
@response = PersistentConnection::PooledResponse.new(@net_response, url: 'https://localhost:23443/candlepin/consumers/uuid-1')
end

def test_body_returns_response_body
assert_equal '{"id":"abc","name":"test"}', @response.body
end

def test_code_returns_integer
assert_equal 200, @response.code
assert_kind_of Integer, @response.code
end

def test_headers_uses_symbol_keys_with_underscores
assert_equal 'req-123', @response.headers[:x_candlepin_request_uuid]
assert_equal '4.3.1', @response.headers[:x_version]
assert_equal 'application/json', @response.headers[:content_type]
end

def test_request_url
assert_equal 'https://localhost:23443/candlepin/consumers/uuid-1', @response.request.url
end

def test_inherits_from_string
assert_kind_of String, @response
assert_equal '{"id":"abc","name":"test"}', @response.to_s
end

def test_json_parseable_as_string
parsed = JSON.parse(@response)
assert_equal 'abc', parsed['id']
end

def test_code_supports_integer_comparison
assert @response.code < 400
error_response = PersistentConnection::PooledResponse.new(
stub(:body => '{}', :code => '404').tap { |r| r.stubs(:each_header) },
url: 'https://localhost/test'
)
assert error_response.code >= 400
end
end

class PersistentConnectionIssueRequestTest < ActiveSupport::TestCase
def setup
@success_response = stub(:body => '{"status":"ok"}', :code => '200')
@success_response.stubs(:each_header).multiple_yields(
['X-Candlepin-Request-Uuid', 'req-456'],
['X-Version', '4.3.1']
)
end

def test_successful_request_returns_pooled_response
mock_http = mock('persistent_http')
mock_http.expects(:request).returns(@success_response)
CandlepinResource.stubs(:persistent_http).returns(mock_http)

result = CandlepinResource.issue_request(method: :get, path: '/candlepin/status', headers: {})

assert_kind_of PersistentConnection::PooledResponse, result
assert_equal 200, result.code
assert_equal '{"status":"ok"}', result.body
end

def test_4xx_raises_restclient_exception
error_response = stub(:body => '{"displayMessage":"Not found"}', :code => '404')
error_response.stubs(:each_header).multiple_yields(['X-Version', '4.3.1'])
mock_http = mock('persistent_http')
mock_http.expects(:request).returns(error_response)
CandlepinResource.stubs(:persistent_http).returns(mock_http)

assert_raises(RestClient::Exception) do
CandlepinResource.issue_request(method: :get, path: '/candlepin/missing', headers: {})
end
end

def test_404_without_x_version_raises_candlepin_not_running
error_response = stub(:body => '<html>404</html>', :code => '404')
error_response.stubs(:each_header)
mock_http = mock('persistent_http')
mock_http.expects(:request).returns(error_response)
CandlepinResource.stubs(:persistent_http).returns(mock_http)

assert_raises(Katello::Errors::CandlepinNotRunning) do
CandlepinResource.issue_request(method: :get, path: '/candlepin/status', headers: {})
end
end

def test_connection_refused_raises_connection_refused_exception
mock_http = mock('persistent_http')
mock_http.expects(:request).raises(Errno::ECONNREFUSED)
CandlepinResource.stubs(:persistent_http).returns(mock_http)

assert_raises(Katello::Errors::ConnectionRefusedException) do
CandlepinResource.issue_request(method: :get, path: '/candlepin/status', headers: {})
end
end

def test_persistent_error_raises_connection_refused_exception
mock_http = mock('persistent_http')
mock_http.expects(:request).raises(Net::HTTP::Persistent::Error.new('too many connection resets'))
CandlepinResource.stubs(:persistent_http).returns(mock_http)

assert_raises(Katello::Errors::ConnectionRefusedException) do
CandlepinResource.issue_request(method: :get, path: '/candlepin/status', headers: {})
end
end

def test_symbol_header_values_are_translated
mock_http = mock('persistent_http')
mock_http.expects(:request).with do |_uri, req|
req['accept'] == 'application/json' && req['content_type'] == 'application/json'
end.returns(@success_response)
CandlepinResource.stubs(:persistent_http).returns(mock_http)

CandlepinResource.issue_request(
method: :get, path: '/candlepin/status',
headers: {accept: :json, content_type: :json}
)
end

def test_upstream_resource_bypasses_pooling
assert_equal false, UpstreamCandlepinResource.use_persistent_connection
end
end
end
end
end
Loading