Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bb1f885
FEAT: CacheManager class for basic caching mechanism
ahasunos Aug 18, 2023
2ab02f3
CHORE: Include the class under RestfulClient module & lint fixes
ahasunos Aug 18, 2023
e3e13db
SPECS: Write tests for CacheManager
ahasunos Aug 18, 2023
0a51cfa
SPECS: Extend few more test for the cache_manager spec
ahasunos Aug 18, 2023
ee839a7
FIX: Fix CI test failing due to missing require; works on local
ahasunos Aug 18, 2023
fcdd1b9
CACHE: Implement caching for /client API
ahasunos Aug 22, 2023
bf59e15
REFACTOR: Extract logic for calculating expiry time from cache-manage…
ahasunos Aug 22, 2023
0cf2a92
FIX: Fix broken test due to caching of the calls during tests
ahasunos Aug 22, 2023
f234ec2
LOGS: Enable logging in cache manager & include some messages,
ahasunos Aug 23, 2023
bd721ab
CHORE: Disable cache during testing to avoid unnecessary caches on th…
ahasunos Aug 23, 2023
6691ff6
SPEC: Test caching feature for the /client api
ahasunos Aug 23, 2023
5ce7efd
CHORE: Fix lint offense
ahasunos Aug 23, 2023
5544832
ENHANCE: Improve key construction for cache
ahasunos Aug 23, 2023
3596640
ENHANCE: Improve methods for fetching ttl from response
ahasunos Aug 28, 2023
19f6253
CHORE: Remove outdated comments
ahasunos Aug 29, 2023
7ca1b46
FIX: Handle timezone correctly while calculating ttl
ahasunos Aug 29, 2023
b8123e8
ENHANCE: Add ability for application to set default ttl
ahasunos Aug 29, 2023
3615f5a
EXTEND: Expose an endpoint from base to clear cache for given endpoints
ahasunos Aug 25, 2023
8b3d245
USAGE: Implement method for client API to help clear cache
ahasunos Aug 25, 2023
52b90fa
SPEC: Test for clearing cache ability
ahasunos Aug 25, 2023
83ec2f1
ENHANCE: Add application-level caching for listLicenses API
ahasunos Sep 4, 2023
9314df5
ENHANCE: Handle status code and multiple license server urls
ahasunos Sep 4, 2023
6f06392
REFACTOR: Transfer caching related responsibilities from base to cach…
ahasunos Sep 6, 2023
b2d1c6c
REFACTOR: Breakdown base class into ApiGateway & FaradayConnHandler c…
ahasunos Sep 6, 2023
87762d7
CHORE: Minor clean-up of comments and code
ahasunos Sep 6, 2023
d0b49c0
REFACTOR: Introduce a common method to handle different http method c…
ahasunos Sep 7, 2023
38f2946
ENHANCE: Improve cache deletion of api response
ahasunos Sep 7, 2023
f342780
CHORE: Move restfulclient module tests in a folder
ahasunos Sep 7, 2023
81e0fd2
SPEC: Introduce test for api gateway class
ahasunos Sep 7, 2023
87eb586
SPEC: Fix incomplete test for apigateway
ahasunos Sep 7, 2023
3a45115
SPEC: Introduce spec for faraday conn handler
ahasunos Sep 7, 2023
6696491
CHORE: Undo v1 spec to its original location
ahasunos Sep 7, 2023
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
4 changes: 4 additions & 0 deletions components/ruby/lib/chef-licensing.rb
Original file line number Diff line number Diff line change
Expand Up @@ -66,5 +66,9 @@ def client(opts = {})
def add_license
ChefLicensing::LicenseKeyFetcher.add_license
end

def clear_client_api_cached_response(opts = {})
ChefLicensing::Api::Client.clear_client_cache(opts)
end
end
end
10 changes: 9 additions & 1 deletion components/ruby/lib/chef-licensing/api/client.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ class << self
def info(opts = {})
new(opts).info
end

def clear_client_cache(opts = {})
new(opts).clear_client_cache
end
end

def initialize(opts = {})
Expand All @@ -31,9 +35,13 @@ def info
end
end

def clear_client_cache
restful_client.clear_cached_response(ChefLicensing::RestfulClient::V1::END_POINTS[:CLIENT], { licenseId: license_keys.join(","), entitlementId: ChefLicensing::Config.chef_entitlement_id })
end

private

attr_reader :restful_client
end
end
end
end
8 changes: 7 additions & 1 deletion components/ruby/lib/chef-licensing/config.rb
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
module ChefLicensing
class Config
class << self
attr_writer :license_server_url, :logger, :output, :license_server_url_check_in_file
attr_writer :license_server_url, :logger, :output, :license_server_url_check_in_file, :cache_enabled

# is_local_license_service is used by context class
attr_accessor :is_local_license_service, :chef_entitlement_id, :chef_product_name, :chef_executable_name
Expand All @@ -28,6 +28,12 @@ def license_server_url(opts = {})
@license_server_url
end

def cache_enabled?
# return cache_enabled if cache_enabled is set, otherwise return true
# useful for testing purposes or when we want to disable cache for some reason
@cache_enabled.nil? ? true : @cache_enabled
Comment thread
ahasunos marked this conversation as resolved.
end

def logger
return @logger if @logger

Expand Down
154 changes: 154 additions & 0 deletions components/ruby/lib/chef-licensing/restful_client/api_gateway.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
require_relative "cache_manager"
require_relative "../config"
require_relative "../exceptions/restful_client_error"
require_relative "../exceptions/restful_client_connection_error"
require_relative "faraday_conn_handler"

module ChefLicensing
module RestfulClient
class ApiGateway
REQUEST_LIMIT = 5

def initialize(opts = {})
@cache_manager = opts[:cache_manager] || ChefLicensing::RestfulClient::CacheManager.new
@logger = ChefLicensing::Config.logger
@faraday_conn_handler = ChefLicensing::RestfulClient::FaradayConnHandler.new
end

# No application-level caching
def fetch_from_server(endpoint, params = {})
logger.debug "Fetching data from server for #{endpoint}"
response = invoke_api(endpoint, :get, nil, params)
response.body
end

def post_to_server(endpoint, payload = {}, headers = {})
response = invoke_api(endpoint, :post, payload, nil, headers)
raise RestfulClientError, format_error_from(response) unless response.success?

response.body
end

# Try to fetch data from application-cache first and if it fails, fallback to server
def fetch_from_cache_or_server(endpoint, params = {})
cached_response = fetch_cached_response(endpoint, params)
if cached_response.nil?
logger.debug "Cache not found for #{endpoint}"
logger.debug "Fetching data from server for #{endpoint}"
response = invoke_api(endpoint, :get, nil, params)
cache_key = @cache_manager.construct_cache_key(endpoint, params)
logger.debug "Storing data in cache for #{endpoint} against key #{cache_key}"
@cache_manager.store(cache_key, response.body) if response.success? && response&.body&.status_code == 200
response.body
else
logger.debug "Cache found for #{endpoint}"
cached_response
end
end

# Try to fetch data from the server first and if it fails, fallback to application-cache
def fetch_from_server_or_cache(endpoint, params = {})
logger.debug "Fetching data from server for #{endpoint}"
response = invoke_api(endpoint, :get, nil, params)
cache_key = @cache_manager.construct_cache_key(endpoint, params)
logger.debug "Storing cache for #{endpoint} with key #{cache_key}"
# TODO: We don't receive cache info in the response body for listLicenses endpoint
# so temporarily we are hardcoding the ttl to 46108 seconds (12 hours); check with the server team
@cache_manager.store(cache_key, response.body, 46108) if response&.body&.status_code == 200 || response&.body&.status_code == 404
response.body
rescue RestfulClientConnectionError => e
logger.debug "Restful Client Connection Error #{e.message}"
logger.debug "Falling back to cache for #{endpoint}"
cached_response = fetch_cached_response(endpoint, params)
raise_restful_client_conn_error if cached_response.nil?
cached_response
end

def clear_cached_response(endpoint, params = {})
urls = ChefLicensing::Config.license_server_url.split(",").first(REQUEST_LIMIT)
urls.each do |url|
cache_key = @cache_manager.construct_cache_key(endpoint, params, url)
if @cache_manager.is_cached?(cache_key)
logger.debug "Clearing cache for #{endpoint} with key #{cache_key}"
@cache_manager.delete(cache_key)
end
end
end

private

attr_reader :cache_manager, :logger

def fetch_cached_response(endpoint, params = {})
urls = ChefLicensing::Config.license_server_url.split(",").first(REQUEST_LIMIT)
response = nil
urls.each do |url|
cache_key = @cache_manager.construct_cache_key(endpoint, params, url)
logger.debug "Checking cache for #{cache_key}"
if @cache_manager.is_cached?(cache_key)
logger.debug "Fetching data from cache for #{cache_key}"
ChefLicensing::Config.license_server_url = url
response = @cache_manager.fetch(cache_key)
break
end
end
response
end

def invoke_api(endpoint, http_method, payload = nil, params = {}, headers = {})
response = nil
urls = ChefLicensing::Config.license_server_url.split(",")

logger.warn "Only the first #{REQUEST_LIMIT} urls will be tried." if urls.size > REQUEST_LIMIT
urls.first(REQUEST_LIMIT).each do |url|
url = url.strip

logger.debug "Trying to connect to #{url}"

response = @faraday_conn_handler.handle_connection(http_method, url) do |connection|
connection.send(http_method, endpoint) do |request|
request.body = payload.to_json if payload
request.params = params if params
request.headers = headers if headers
end
end

# At this point, we have a successful connection
# Update the value of license server url in config
ChefLicensing::Config.license_server_url = url
logger.debug "Connection succeeded to #{url}"
break response
rescue RestfulClientConnectionError
logger.warn "Connection failed to #{url}"
rescue URI::InvalidURIError
logger.warn "Invalid URI #{url}"
end

raise_restful_client_conn_error if response.nil?
response
end

def format_error_from(response)
error_details = response.body&.data&.error
return response.reason_phrase unless error_details

error_details
end

def raise_restful_client_conn_error
urls = ChefLicensing::Config.license_server_url.split(",").first(REQUEST_LIMIT)
error_message = <<~EOM
Unable to connect to the licensing server. #{ChefLicensing::Config.chef_product_name} requires server communication to operate.
The following URL(s) were tried:\n#{
urls.each_with_index.map do |url, index|
"#{index + 1}. #{url}"
end.join("\n")
}
EOM

raise RestfulClientConnectionError, error_message
end
end
end
end

130 changes: 26 additions & 104 deletions components/ruby/lib/chef-licensing/restful_client/base.rb
Original file line number Diff line number Diff line change
@@ -1,12 +1,6 @@
require "faraday" unless defined?(Faraday)
require "faraday/http_cache"
require "active_support"
require "tmpdir" unless defined?(Dir.mktmpdir)
require_relative "../exceptions/restful_client_error"
require_relative "../exceptions/restful_client_connection_error"
require_relative "../exceptions/missing_api_credentials_error"
require_relative "../config"
require_relative "middleware/exceptions_handler"
require_relative "api_gateway"

module ChefLicensing
module RestfulClient
Expand All @@ -21,13 +15,23 @@ class Base
ENTITLEMENT_BY_ID: "license-service/entitlementbyid",
}.freeze

# Cache first endpoints are the endpoints where we want to fetch data from cache first
# and if it fails, fallback to server
CACHE_FIRST_ENDPOINTS = [
].freeze

# API first endpoints are the endpoints where we want to fetch data from server first
# and if it fails, fallback to cache
API_FIRST_ENDPOINTS = [
].freeze

CURRENT_ENDPOINT_VERSION = 2
REQUEST_LIMIT = 5

def initialize
def initialize(opts = {})
raise MissingAPICredentialsError, "Missing credential in config: Set in block chef_license_server or use environment variable CHEF_LICENSE_SERVER or pass through argument --chef-license-server" if ChefLicensing::Config.license_server_url.nil?

@logger = ChefLicensing::Config.logger
@api_gateway = opts[:api_gateway] || ChefLicensing::RestfulClient::ApiGateway.new(opts)
end

def validate(license)
Expand Down Expand Up @@ -62,111 +66,29 @@ def list_licenses(params = {})
invoke_get_api(self.class::END_POINTS[:LIST_LICENSES])
end

def clear_cached_response(endpoint, params = {})
logger.debug("Clearing cache for #{endpoint} with params #{params}")
@api_gateway.clear_cached_response(endpoint, params)
end

private

attr_reader :logger

# a common method to handle the get API calls
def invoke_get_api(endpoint, params = {})
response = invoke_api(ChefLicensing::Config.license_server_url.split(","), endpoint, :get, nil, params)
response.body
if self.class::API_FIRST_ENDPOINTS.include?(endpoint) && ChefLicensing::Config.cache_enabled?
@api_gateway.fetch_from_server_or_cache(endpoint, params)
elsif self.class::CACHE_FIRST_ENDPOINTS.include?(endpoint) && ChefLicensing::Config.cache_enabled?
@api_gateway.fetch_from_cache_or_server(endpoint, params)
else
@api_gateway.fetch_from_server(endpoint, params)
end
end

# a common method to handle the post API calls
def invoke_post_api(endpoint, payload, headers = {})
response = invoke_api(ChefLicensing::Config.license_server_url.split(","), endpoint, :post, payload, nil, headers)
raise RestfulClientError, format_error_from(response) unless response.success?

response.body
end

def invoke_api(urls, endpoint, http_method, payload = nil, params = {}, headers = {})
handle_connection = http_method == :get ? method(:handle_get_connection) : method(:handle_post_connection)
response = nil
attempted_urls = []

logger.warn "Only the first #{REQUEST_LIMIT} urls will be tried." if urls.size > REQUEST_LIMIT
urls.each_with_index do |url, i|
url = url.strip
attempted_urls << url
break if i == REQUEST_LIMIT - 1

logger.debug "Trying to connect to #{url}"
handle_connection.call(url) do |connection|
response = connection.send(http_method, endpoint) do |request|
request.body = payload.to_json if payload
request.params = params if params
request.headers = headers if headers
end
end
# At this point, we have a successful connection
# Update the value of license server url in config
ChefLicensing::Config.license_server_url = url
logger.debug "Connection succeeded to #{url}"
break response
rescue RestfulClientConnectionError
logger.warn "Connection failed to #{url}"
rescue URI::InvalidURIError
logger.warn "Invalid URI #{url}"
end

raise_restful_client_conn_error(attempted_urls) if response.nil?
response
end

def handle_get_connection(url = nil)
# handle faraday errors
yield get_connection(url)
rescue Faraday::ClientError => e
logger.debug "Restful Client Error #{e.message}"
raise RestfulClientError, e.message
end

def handle_post_connection(url = nil)
# handle faraday errors
yield post_connection(url)
rescue Faraday::ClientError => e
logger.debug "Restful Client Error #{e.message}"
raise RestfulClientError, e.message
end

def get_connection(url = nil)
store = ::ActiveSupport::Cache.lookup_store(:file_store, Dir.tmpdir)
Faraday.new(url: url) do |config|
config.request :json
config.response :json, parser_options: { object_class: OpenStruct }
config.use Faraday::HttpCache, shared_cache: false, logger: logger, store: store
config.use Middleware::ExceptionsHandler
config.adapter Faraday.default_adapter
end
end

def post_connection(url = nil)
Faraday.new(url: url) do |config|
config.request :json
config.response :json, parser_options: { object_class: OpenStruct }
config.use Middleware::ExceptionsHandler
end
end

def format_error_from(response)
error_details = response.body&.data&.error
return response.reason_phrase unless error_details

error_details
end

def raise_restful_client_conn_error(urls)
error_message = <<~EOM
Unable to connect to the licensing server. #{ChefLicensing::Config.chef_product_name} requires server communication to operate.
The following URL(s) were tried:\n#{
urls.each_with_index.map do |url, index|
"#{index + 1}. #{url}"
end.join("\n")
}
EOM

raise RestfulClientConnectionError, error_message
@api_gateway.post_to_server(endpoint, payload, headers)
end
end
end
Expand Down
Loading